"""REFUGE training/evaluation helper.

Usage examples (after activating .venv_refuge):

  python refuge_build.py --train-clf
  python refuge_build.py --eval --with-ttt

The script expects the REFUGE folder and writes checkpoints under
models/v2/refuge/segmentation and models/v2/refuge/classifier.
"""

from __future__ import annotations

import argparse
import csv
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Set, Tuple
import shutil
import sys

import torch
import numpy as np
from PIL import Image, ImageDraw
from torch.utils.data import DataLoader
from sklearn.metrics import roc_auc_score
from tqdm import tqdm
from torch import nn
from torchvision import models

REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(REPO_ROOT))

from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample
from classes.refuge_segmentation import RefugeSegmentation
from classes.refuge_classification import (
    RefugeClassification,
    RefugeClassificationRecord,
    RefugeClassificationDataset,
    _default_image_transform,
    _geometry_from_mask,
    UNetGeometryProvider,
)
from classes.unet_segmenter import UNetSegmenter
from classes.papila_builders import build_papila_clinical

REFUGE_ROOT = Path("REFUGE")
SEG_CKPT = Path("models/refuge/segmentation/refuge_segmentation_best.pt")
CLF_DIR = Path("models/v2/refuge/classifier")
UNET_WEIGHT_CANDIDATES = (
    Path("models/v2/refuge/segmentation/per_image/best.pt"),
    Path("models/v2/refuge/segmentation/best.pt"),
    Path("models/unet_segmenter/best.pt"),
)

CLASSIFIER_BACKBONES = {
    "resnet50": models.ResNet50_Weights.DEFAULT,
    "densenet121": models.DenseNet121_Weights.DEFAULT,
    "efficientnet_b0": models.EfficientNet_B0_Weights.DEFAULT,
    "efficientnet_b7": models.EfficientNet_B7_Weights.DEFAULT,
}


def build_classifier_backbone(name: str) -> nn.Module:
    name = name.lower()
    if name not in CLASSIFIER_BACKBONES:
        raise ValueError(f"Unsupported classifier backbone '{name}'")

    weights = CLASSIFIER_BACKBONES[name]

    if name == "resnet50":
        model = models.resnet50(weights=weights)
        feat_dim = model.fc.in_features
        model.fc = nn.Identity()
    elif name == "densenet121":
        model = models.densenet121(weights=weights)
        feat_dim = model.classifier.in_features
        model.classifier = nn.Identity()
    elif name == "efficientnet_b0":
        model = models.efficientnet_b0(weights=weights)
        feat_dim = model.classifier[-1].in_features  # type: ignore[index]
        model.classifier = nn.Identity()
    elif name == "efficientnet_b7":
        model = models.efficientnet_b7(weights=weights)
        feat_dim = model.classifier[-1].in_features  # type: ignore[index]
        model.classifier = nn.Identity()
    else:  # pragma: no cover
        raise ValueError(f"Unsupported classifier backbone '{name}'")

    setattr(model, "_feature_dim", int(feat_dim))
    return model


def classifier_checkpoint_dir(backbone_name: str) -> Path:
    return CLF_DIR / backbone_name


def classifier_checkpoint_path(backbone_name: str) -> Path:
    return classifier_checkpoint_dir(backbone_name) / "refuge_classifier_best.pt"


def resolve_unet_weights(explicit: Optional[Path]) -> Path:
    if explicit is not None:
        return explicit
    for cand in UNET_WEIGHT_CANDIDATES:
        if cand.exists():
            return cand
    return UNET_WEIGHT_CANDIDATES[0]


def ensure_preprocessing() -> RefugePreprocessing:
    if not REFUGE_ROOT.exists():
        raise FileNotFoundError(f"REFUGE directory not found at {REFUGE_ROOT}")
    return RefugePreprocessing(REFUGE_ROOT)


def load_allowed_ids(
    csv_path: Optional[Path], dice_threshold: float
) -> Optional[Set[str]]:
    if csv_path is None or not csv_path.exists():
        return None
    allowed: Set[str] = set()
    with csv_path.open(newline="") as fh:
        reader = csv.DictReader(fh)
        for row in reader:
            sample_id = row.get("sample_id")
            if not sample_id or sample_id == "__mean__":
                continue
            try:
                disc = float(row.get("dice_disc", "nan"))
                cup = float(row.get("dice_cup", "nan"))
            except (TypeError, ValueError):
                continue
            if disc < dice_threshold and cup < dice_threshold:
                continue
            allowed.add(sample_id)
    return allowed


def build_papila_samples(
    image_dir: Path,
    clinical_dir: Path,
    label_col: str,
    positive_labels: Sequence[str],
    allowed_ids: Optional[Set[str]],
) -> List[RefugeSample]:
    clinical = build_papila_clinical(
        image_dir=str(image_dir),
        clinical_dir=str(clinical_dir),
        label_col=label_col,
        cat_cols=[],
    )
    positives = {lbl.lower() for lbl in positive_labels}
    samples: Dict[str, RefugeSample] = {}
    for _, row in clinical.df.iterrows():
        image_path = clinical.get_image_path(row)
        sample_id = f"papila_{Path(image_path).stem}"
        if allowed_ids is not None and sample_id not in allowed_ids:
            continue
        if sample_id in samples:
            continue
        value = row.get(label_col)
        if value is None or (isinstance(value, float) and np.isnan(value)):
            continue
        try:
            label_int = int(value)
            if label_int == 2:
                continue
            label = 1 if label_int > 0 else 0
        except (TypeError, ValueError):
            label = 1 if str(value).strip().lower() in positives else 0
        samples[sample_id] = RefugeSample(
            sample_id=sample_id,
            dataset="papila",
            split="holdout",
            image_path=Path(image_path),
            label=label,
            device=None,
            mask_path=None,
            fovea_coord=None,
        )
    return list(samples.values())


def load_contour(path: Path) -> np.ndarray:
    coords = np.loadtxt(path)
    if coords.ndim == 1:
        coords = coords.reshape(-1, 2)
    return coords


def contour_to_mask(coords: np.ndarray, size: Tuple[int, int]) -> np.ndarray:
    if coords is None or coords.size == 0:
        return np.zeros((size[1], size[0]), dtype=np.uint8)
    img = Image.new("L", size, 0)
    draw = ImageDraw.Draw(img)
    points = [tuple(map(float, pt)) for pt in coords]
    draw.polygon(points, outline=1, fill=1)
    return np.array(img, dtype=np.uint8)


class PapilaGTGeometryProvider:
    def __init__(self, contours_dir: Path) -> None:
        self.contours_dir = contours_dir

    def _pick(self, base: str, kind: str) -> Optional[Path]:
        for exp in ("exp2", "exp1"):
            cand = self.contours_dir / f"{base}_{kind}_{exp}.txt"
            if cand.exists():
                return cand
        return None

    def __call__(self, sample: RefugeSample, scale: float):
        base = Path(sample.image_path).stem
        disc_path = self._pick(base, "disc")
        cup_path = self._pick(base, "cup")
        if disc_path is None or cup_path is None:
            raise RuntimeError(f"Missing ground-truth contours for {sample.sample_id}")

        image = Image.open(sample.image_path).convert("RGB")
        disc_coords = load_contour(disc_path)
        cup_coords = load_contour(cup_path)
        disc_mask = contour_to_mask(disc_coords, image.size)
        cup_mask = contour_to_mask(cup_coords, image.size)
        cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8)
        geom = _geometry_from_mask(disc_mask, scale)
        return geom, disc_mask.astype(np.uint8), cup_mask.astype(np.uint8)


def build_papila_records(
    args: argparse.Namespace,
    pre: RefugePreprocessing,
    checkpoint_path: Path,
) -> Tuple[List[RefugeClassificationRecord], Optional[RefugeClassification]]:
    allowed = load_allowed_ids(
        getattr(args, "papila_metrics", None),
        getattr(args, "papila_dice_threshold", 0.01),
    )
    samples = build_papila_samples(
        args.papila_image_dir,
        args.papila_clinical_dir,
        args.papila_label_col,
        args.papila_positive_labels,
        allowed,
    )
    if not samples:
        return [], None

    cache_dir = args.clf_cache_dir
    if cache_dir is not None and getattr(args, "papila_use_gt", False):
        cache_dir = cache_dir / "gt"

    if getattr(args, "papila_use_gt", False):
        geometry_fn = PapilaGTGeometryProvider(args.papila_contours_dir)
        provider = geometry_fn
    else:
        seg_manifest = getattr(args, "seg_manifest", None)
        seg_weights = resolve_unet_weights(getattr(args, "seg_weights", None))
        if seg_manifest is None or seg_weights is None:
            raise SystemExit(
                "Papila evaluation without GT masks requires --seg-manifest and --seg-weights"
            )
        segmenter = UNetSegmenter(
            manifest_path=seg_manifest,
            device=args.device,
            normalize=args.seg_normalize,
        )
        seg_state = torch.load(seg_weights, map_location=args.device)
        seg_state_dict = seg_state.get("model", seg_state)
        segmenter.model.load_state_dict(seg_state_dict)
        segmenter.model.to(args.device)
        provider = UNetGeometryProvider(
            segmenter=segmenter,
            threshold=args.segmenter_threshold,
            tta=args.segmenter_tta,
        )
        geometry_fn = provider

    papila_seg = RefugeSegmentation(pre)
    backbone = build_classifier_backbone(args.clf_backbone)
    papila_clf = RefugeClassification(
        pre,
        papila_seg,
        backbone=backbone,
        geometry_fn=provider,
        cache_dir=cache_dir,
    )
    papila_clf.crop_scale = args.crop_scale
    papila_clf.crop_size = args.crop_size
    papila_clf.eval_transform = _default_image_transform(args.crop_size)
    papila_clf.ttt_transform = papila_clf.eval_transform
    papila_state = torch.load(checkpoint_path, map_location=args.device)
    papila_clf.backbone.load_state_dict(papila_state["backbone"])
    papila_clf.classifier_head.load_state_dict(papila_state["classifier"])
    papila_clf.rotation_head.load_state_dict(papila_state["rotation"])
    papila_clf.backbone.to(args.device)
    papila_clf.classifier_head.to(args.device)
    papila_clf.rotation_head.to(args.device)
    if getattr(args, "clear_clf_cache", False):
        papila_clf.clear_disk_cache()

    records = papila_clf.build_records_for_samples(
        samples, crop_scale=args.crop_scale, progress_prefix="papila"
    )
    print(f"[eval] Prepared {len(records)} PAPILA records")
    return records, papila_clf


def train_unet_segmenter(args: argparse.Namespace) -> None:
    manifest_path = args.seg_manifest or Path("manifest.csv")
    mask_cache_dir = None if args.in_memory_cache else args.mask_cache_dir
    image_cache_dir = None if args.in_memory_cache else args.image_cache_dir
    if args.in_memory_cache and (args.mask_cache_dir or args.image_cache_dir):
        print("[unet-seg] in_memory_cache enabled: disk caches disabled for this run.")

    segmenter = UNetSegmenter(
        manifest_path=manifest_path,
        device=args.device,
        target_size=args.seg_image_size,
        normalize=args.seg_normalize,
        use_stronger_aug=args.seg_strong_aug,
        train_datasets=args.seg_train_datasets,
        val_datasets=args.seg_val_datasets,
        holdout_datasets=args.seg_holdout_datasets,
        mask_cache_dir=mask_cache_dir,
        image_cache_dir=image_cache_dir,
        in_memory_cache=args.in_memory_cache,
        loader_workers=args.loader_workers,
    )
    if mask_cache_dir:
        print(f"[unet-seg] mask_cache_dir={mask_cache_dir}")
    if image_cache_dir:
        print(f"[unet-seg] image_cache_dir={image_cache_dir}")
    if args.in_memory_cache:
        print("[unet-seg] prebuilding in-memory cache")
        segmenter.prebuild_in_memory_cache(
            cache_workers=max(0, int(args.cache_workers)),
            include_train=True,
            include_val=True,
            include_holdout=False,
        )

    segmenter.train(
        epochs=args.seg_epochs,
        batch_size=args.seg_batch_size,
        lr=args.seg_lr,
        weight_decay=args.seg_weight_decay,
        checkpoint_dir=args.seg_checkpoint_dir,
    )
    print(
        "[unet-seg] Training complete. Best checkpoint stored at",
        (args.seg_checkpoint_dir / "best.pt").resolve(),
    )


def _load_segmentation(
    pre: RefugePreprocessing, args: argparse.Namespace
) -> RefugeSegmentation:
    seg = RefugeSegmentation(pre)
    seg.build_datasets(
        image_size=args.seg_image_size,
        batch_size=args.seg_batch_size,
        num_workers=args.num_workers,
    )
    if not SEG_CKPT.exists():
        raise FileNotFoundError(f"Segmentation checkpoint missing: {SEG_CKPT}")
    state = torch.load(SEG_CKPT, map_location=args.device)
    seg.model.load_state_dict(state)
    seg.model.to(args.device)
    return seg


def train_classifier(args: argparse.Namespace) -> None:
    pre = ensure_preprocessing()
    seg = _load_segmentation(pre, args)
    backbone = build_classifier_backbone(args.clf_backbone)

    print(f"[classifier] Using backbone: {args.clf_backbone}")

    clf = RefugeClassification(
        pre,
        seg,
        backbone=backbone,
        cache_dir=args.clf_cache_dir,
        use_all_labeled=args.clf_use_all,
        auto_val_ratio=args.clf_auto_val_ratio,
    )
    if args.clear_clf_cache:
        clf.clear_disk_cache()
    clf.build_datasets(
        crop_scale=args.crop_scale,
        crop_size=args.crop_size,
        batch_size=args.clf_batch_size,
        num_workers=args.num_workers,
    )

    default_ckpt_path = classifier_checkpoint_path(args.clf_backbone)
    ckpt_path = args.clf_checkpoint_path or default_ckpt_path
    ckpt_dir = ckpt_path.parent
    history = clf.train(
        epochs=args.clf_epochs,
        lr=args.clf_lr,
        weight_decay=args.clf_weight_decay,
        rotation_weight=args.rotation_weight,
        checkpoint_dir=ckpt_dir,
        device=args.device,
    )
    print("Classifier training complete. Best AUC:", history.get("best_auc"))
    print(f"Checkpoint directory: {ckpt_dir}")
    saved_path = ckpt_dir / "refuge_classifier_best.pt"
    if ckpt_path != saved_path:
        ckpt_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(saved_path, ckpt_path)
        print(f"Checkpoint copied to: {ckpt_path}")


def _load_classifier(
    pre: RefugePreprocessing, seg: RefugeSegmentation, args: argparse.Namespace
) -> Tuple[RefugeClassification, Path]:
    backbone = build_classifier_backbone(args.clf_backbone)
    clf = RefugeClassification(
        pre,
        seg,
        backbone=backbone,
        cache_dir=args.clf_cache_dir,
        use_all_labeled=args.clf_use_all,
        auto_val_ratio=args.clf_auto_val_ratio,
    )
    clf.build_datasets(
        crop_scale=args.crop_scale,
        crop_size=args.crop_size,
        batch_size=args.clf_batch_size,
        num_workers=args.num_workers,
    )
    ckpt_path = args.clf_checkpoint_path or classifier_checkpoint_path(
        args.clf_backbone
    )
    if not ckpt_path.exists():
        raise FileNotFoundError(f"Classifier checkpoint missing: {ckpt_path}")
    print(f"[classifier] Loading checkpoint: {ckpt_path}")
    state = torch.load(ckpt_path, map_location=args.device)
    clf.backbone.load_state_dict(state["backbone"])
    clf.classifier_head.load_state_dict(state["classifier"])
    clf.rotation_head.load_state_dict(state["rotation"])
    clf.backbone.to(args.device)
    clf.classifier_head.to(args.device)
    clf.rotation_head.to(args.device)
    return clf, ckpt_path


def _collect_records(
    pre: RefugePreprocessing,
    seg: RefugeSegmentation,
    clf: RefugeClassification,
    dataset_name: str,
    split: str,
    scale: float,
) -> List[RefugeClassificationRecord]:
    manifest = pre.build_manifest()
    samples = [
        sample
        for sample in manifest
        if sample.dataset == dataset_name
        and sample.split == split
        and sample.label is not None
    ]
    if not samples:
        return []
    print(f"[eval] Preparing {len(samples)} samples for {dataset_name.upper()} {split}")
    return clf.build_records_for_samples(
        samples, crop_scale=scale, progress_prefix=f"{dataset_name}_{split}"
    )


def _auc_for_records(
    clf: RefugeClassification,
    records: List[RefugeClassificationRecord],
    device: str,
) -> float:
    if not records:
        return float("nan")
    dataset = RefugeClassificationDataset(
        records,
        transform=clf.eval_transform,
        polar_transform=clf.polar_transform,
        size=clf.crop_size,
    )
    loader = DataLoader(dataset, batch_size=64, shuffle=False, num_workers=0)
    clf.backbone.to(device).eval()
    clf.classifier_head.to(device).eval()
    preds: List[float] = []
    targets: List[int] = []
    with torch.no_grad():
        for batch in tqdm(loader, desc="Eval", leave=False, unit="batch"):
            images = batch["image"].to(device)
            polars = batch["polar"].to(device)
            extra_feats = batch["features"].to(device)
            labels = batch["label"].cpu().numpy().tolist()
            feats_img = clf.backbone(images)
            feats = feats_img
            if getattr(clf, "use_polar", False):
                feats_polar = clf.backbone(polars)
                feats = torch.cat([feats, feats_polar], dim=1)
            if getattr(clf, "extra_feature_dim", 0) > 0:
                feats = torch.cat([feats, extra_feats], dim=1)
            logits = clf.classifier_head(feats)
            probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy().tolist()
            preds.extend(probs)
            targets.extend(labels)
    if len(set(targets)) < 2:
        return float("nan")
    return float(roc_auc_score(targets, preds))


def evaluate(args: argparse.Namespace) -> None:
    pre = ensure_preprocessing()
    seg = _load_segmentation(pre, args)
    clf, clf_ckpt = _load_classifier(pre, seg, args)
    if args.clear_clf_cache:
        clf.clear_disk_cache()

    def evaluate_subset(
        clf_obj: RefugeClassification,
        records: List[RefugeClassificationRecord],
        label: str,
    ) -> None:
        if not records:
            print(f"[eval] No samples found for {label}; skipping.")
            return

        base_state = {
            "backbone": clf_obj.backbone.state_dict(),
            "rotation": clf_obj.rotation_head.state_dict(),
        }

        auc_no_ttt = _auc_for_records(clf_obj, records, device=args.device)

        auc_ttt = float("nan")
        if args.with_ttt:
            ttt_loader = DataLoader(
                RefugeClassificationDataset(
                    records,
                    transform=clf_obj.ttt_transform,
                    polar_transform=clf_obj.polar_transform,
                    size=clf_obj.crop_size,
                ),
                batch_size=16,
                shuffle=False,
                num_workers=0,
            )
            ttt_iter = tqdm(range(args.ttt_steps), desc="TTT", unit="step")
            for _ in ttt_iter:
                clf_obj.apply_ttt(ttt_loader, device=args.device, steps=1)
            auc_ttt = _auc_for_records(clf_obj, records, device=args.device)
            clf_obj.backbone.load_state_dict(base_state["backbone"])
            clf_obj.rotation_head.load_state_dict(base_state["rotation"])

        print(
            f"{label}: AUC (no TTT) = {auc_no_ttt:.4f}"
            + (f", AUC (TTT) = {auc_ttt:.4f}" if args.with_ttt else "")
        )

    if args.eval_datasets:
        for dataset_name in dict.fromkeys(args.eval_datasets):
            if dataset_name.lower() == "papila":
                papila_records, papila_clf = build_papila_records(args, pre, clf_ckpt)
                if papila_clf is None:
                    print("[eval] Papila evaluation aborted; no samples built.")
                else:
                    evaluate_subset(papila_clf, papila_records, "PAPILA holdout")
            else:
                records = _collect_records(
                    pre,
                    seg,
                    clf,
                    dataset_name,
                    "holdout",
                    scale=args.crop_scale,
                )
                evaluate_subset(clf, records, f"{dataset_name.upper()} holdout")
        return

    # Do not mix splits: report per dataset + split
    subsets = [
        ("refuge1", "val"),
        ("refuge2", "val"),
        ("refuge2", "test"),
    ]

    for dataset_name, split in subsets:
        records = _collect_records(
            pre, seg, clf, dataset_name, split, scale=args.crop_scale
        )
        evaluate_subset(clf, records, f"{dataset_name.upper()} {split}")

        if args.dump_masks and dataset_name == "refuge1" and split == "val":
            out_dir = Path(args.dump_masks)
            out_dir.mkdir(parents=True, exist_ok=True)
            for rec in records:
                sample = rec.sample
                if sample is None:
                    continue
                pred = seg.predict_mask(sample, device=args.device).numpy()
                Image.fromarray((pred * 255).astype(np.uint8)).save(
                    out_dir / f"{sample.sample_id}_pred.png"
                )
                if sample.mask_path and sample.mask_path.exists():
                    Image.open(sample.mask_path).convert("L").save(
                        out_dir / f"{sample.sample_id}_gt.png"
                    )


def evaluate_segmentation(args: argparse.Namespace) -> None:
    manifest_path = args.seg_manifest or Path("manifest.csv")
    mask_cache_dir = None if args.in_memory_cache else args.mask_cache_dir
    image_cache_dir = None if args.in_memory_cache else args.image_cache_dir

    segmenter = UNetSegmenter(
        manifest_path=manifest_path,
        normalize=args.seg_normalize,
        device=args.device,
        mask_cache_dir=mask_cache_dir,
        image_cache_dir=image_cache_dir,
        in_memory_cache=args.in_memory_cache,
        loader_workers=args.loader_workers,
    )
    if args.seg_weights is None:
        raise SystemExit(
            "--seg-weights must be specified for --eval-seg; "
            "e.g. --seg-weights models/v2/refuge/segmentation/per_image_refuge_build/best.pt"
        )
    ckpt = args.seg_weights
    if not ckpt.exists():
        raise FileNotFoundError(f"Segmentation weights not found at {ckpt}")
    state = torch.load(ckpt, map_location=segmenter.device)
    state_dict = state.get("model", state)
    segmenter.model.load_state_dict(state_dict, strict=False)
    print(f"[seg-eval] Loaded weights from {ckpt}")

    if args.in_memory_cache:
        segmenter.prebuild_in_memory_cache(
            cache_workers=max(0, int(args.cache_workers)),
            include_train=False,
            include_val="val" in args.eval_seg_splits,
            include_holdout="holdout" in args.eval_seg_splits,
        )

    dataset_filter = args.eval_seg_datasets
    split_filter = args.eval_seg_splits
    output_dir = args.eval_seg_output or Path("analysis_data/segmenter_eval")
    metrics_path = args.eval_seg_metrics_path

    segmenter.evaluate_dataset(
        dataset_filter=dataset_filter,
        split_filter=split_filter,
        output_dir=output_dir,
        save_overlays=not args.eval_seg_no_overlays,
        metrics_path=metrics_path,
        threshold=args.eval_seg_threshold,
        tta=args.eval_seg_tta,
    )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="REFUGE pipeline helper")
    parser.add_argument(
        "--train-unet-seg",
        action="store_true",
        help="Train the UNet segmenter (replacement for scripts/run_unet_segmenter.py)",
    )
    parser.add_argument(
        "--train-clf", action="store_true", help="Train the classification model"
    )
    parser.add_argument(
        "--eval", action="store_true", help="Run evaluation on stored checkpoints"
    )
    parser.add_argument(
        "--with-ttt",
        action="store_true",
        help="Apply test-time training during evaluation",
    )
    parser.add_argument(
        "--ttt-steps", type=int, default=1, help="TTT epochs over evaluation loader"
    )
    parser.add_argument(
        "--export-backbone",
        type=Path,
        default=None,
        help="Optional path to export the trained backbone weights",
    )
    parser.add_argument(
        "--dump-masks",
        type=Path,
        default=None,
        help="Optional directory to dump predicted/GT masks during eval",
    )
    parser.add_argument(
        "--device", default="cuda" if torch.cuda.is_available() else "cpu"
    )
    parser.add_argument("--num-workers", type=int, default=4)
    # Segmentation hyperparameters
    parser.add_argument("--seg-epochs", type=int, default=40)
    parser.add_argument("--seg-lr", type=float, default=1e-3)
    parser.add_argument("--seg-weight-decay", type=float, default=1e-5)
    parser.add_argument("--seg-image-size", type=int, default=512)
    parser.add_argument("--seg-batch-size", type=int, default=4)
    parser.add_argument(
        "--seg-manifest",
        type=Path,
        default=Path("manifest.csv"),
        help="Manifest CSV for the UNet segmenter (default: manifest.csv)",
    )
    parser.add_argument(
        "--seg-weights",
        type=Path,
        default=None,
        help="Path to UNet segmenter weights (default: models/unet_segmenter/best.pt)",
    )
    parser.add_argument(
        "--seg-normalize",
        choices=["none", "imagenet", "per_image"],
        default="none",
        help="Normalization mode used when running the UNet segmenter",
    )
    parser.add_argument(
        "--seg-strong-aug",
        action="store_true",
        help="Enable stronger geometric augmentations when training the UNet segmenter",
    )
    parser.add_argument(
        "--seg-train-datasets",
        nargs="+",
        default=["refuge"],
        help="Datasets to use for UNet segmenter training (default: refuge)",
    )
    parser.add_argument(
        "--seg-val-datasets",
        nargs="+",
        default=["refuge"],
        help="Datasets eligible for validation sampling (default: refuge)",
    )
    parser.add_argument(
        "--seg-holdout-datasets",
        nargs="+",
        default=["refuge"],
        help="Datasets reserved for holdout set during UNet segmenter training (default: refuge)",
    )
    parser.add_argument(
        "--seg-checkpoint-dir",
        type=Path,
        default=Path("models/v2/refuge/segmentation/per_image"),
        help="Directory to store UNet segmenter checkpoints",
    )
    parser.add_argument(
        "--loader-workers",
        type=int,
        default=0,
        help="DataLoader workers for UNet segmenter train/eval.",
    )
    parser.add_argument(
        "--mask-cache-dir",
        type=Path,
        default=None,
        help="Optional cache dir for parsed/resized disc+cup masks.",
    )
    parser.add_argument(
        "--image-cache-dir",
        type=Path,
        default=None,
        help="Optional cache dir for resized RGB images before augmentation.",
    )
    parser.add_argument(
        "--in-memory-cache",
        action="store_true",
        help="Cache preprocessed images and masks in RAM (per DataLoader worker process).",
    )
    parser.add_argument(
        "--cache-workers",
        type=int,
        default=0,
        help="Worker threads for prebuilding in-memory cache before training/eval.",
    )
    # Classification hyperparameters
    parser.add_argument("--clf-epochs", type=int, default=30)
    parser.add_argument("--clf-lr", type=float, default=1e-4)
    parser.add_argument("--clf-weight-decay", type=float, default=1e-4)
    parser.add_argument("--clf-batch-size", type=int, default=16)
    parser.add_argument(
        "--clf-backbone",
        choices=sorted(CLASSIFIER_BACKBONES.keys()),
        default="resnet50",
        help="Backbone architecture for the REFUGE classifier",
    )
    parser.add_argument("--rotation-weight", type=float, default=0.5)
    parser.add_argument("--crop-scale", type=float, default=2.5)
    parser.add_argument("--crop-size", type=int, default=224)
    parser.add_argument(
        "--clf-cache-dir",
        type=Path,
        default=Path("cache_data/classifier_cache"),
        help="Directory to cache classifier preprocessing artifacts",
    )
    parser.add_argument(
        "--clear-clf-cache",
        action="store_true",
        help="Delete all cached geometry/mask files before running (use when segmenter weights have changed)",
    )
    parser.add_argument(
        "--clf-use-all",
        action="store_true",
        help="Use all labelled samples (train+val) when building classifier dataset",
    )
    parser.add_argument(
        "--clf-auto-val-ratio",
        type=float,
        default=0.1,
        help="Fraction for automatic validation split when no explicit val set is used",
    )
    parser.add_argument(
        "--clf-checkpoint-path",
        type=Path,
        default=None,
        help="Optional explicit path for the classifier checkpoint (defaults to models/v2/refuge/classifier/<backbone>/refuge_classifier_best.pt)",
    )
    parser.add_argument(
        "--eval-datasets",
        nargs="+",
        help="Datasets to evaluate during --eval (e.g. papila). Defaults to REFUGE splits.",
    )
    # Segmentation evaluation parameters
    parser.add_argument(
        "--eval-seg",
        action="store_true",
        help="Evaluate the segmentation model on specified datasets/splits",
    )
    parser.add_argument(
        "--eval-seg-datasets",
        nargs="+",
        default=["refuge"],
        help="Segmentation datasets to evaluate (default: refuge)",
    )
    parser.add_argument(
        "--eval-seg-splits",
        nargs="+",
        choices=["train", "val", "holdout"],
        default=["holdout"],
        help="Segmentation splits to evaluate (default: holdout)",
    )
    parser.add_argument(
        "--eval-seg-output",
        type=Path,
        default=Path("analysis_data/segmenter_eval"),
        help="Directory to store segmentation metrics CSVs",
    )
    parser.add_argument(
        "--eval-seg-threshold",
        type=float,
        default=0.5,
        help="Threshold for binarising predicted masks during segmentation eval",
    )
    parser.add_argument(
        "--eval-seg-metrics-path",
        type=Path,
        default=None,
        help="Optional explicit CSV path for segmentation metrics output",
    )
    parser.add_argument(
        "--eval-seg-no-overlays",
        action="store_true",
        help="Skip saving GT/pred overlay images during segmentation evaluation",
    )
    parser.add_argument(
        "--eval-seg-tta",
        action="store_true",
        help="Enable horizontal/vertical flip TTA during segmentation evaluation",
    )
    parser.add_argument(
        "--papila-metrics",
        type=Path,
        default=None,
        help="Optional CSV of Papila Dice metrics used to filter samples",
    )
    parser.add_argument(
        "--papila-dice-threshold",
        type=float,
        default=0.01,
        help="Minimum Dice required (disc or cup) when filtering Papila metrics",
    )
    parser.add_argument(
        "--papila-positive-labels",
        nargs="+",
        default=["glaucoma", "glaucoma suspect", "suspect"],
        help="Papila label values treated as positive when labels are non-numeric",
    )
    parser.add_argument(
        "--papila-image-dir",
        type=Path,
        default=Path("Papila/FundusImages"),
        help="Path to Papila fundus images",
    )
    parser.add_argument(
        "--papila-clinical-dir",
        type=Path,
        default=Path("Papila/ClinicalData"),
        help="Path to Papila clinical CSVs",
    )
    parser.add_argument(
        "--papila-label-col",
        type=str,
        default="Diagnosis",
        help="Column name containing Papila labels",
    )
    parser.add_argument(
        "--papila-use-gt",
        action="store_true",
        help="Use Papila ground-truth contours when evaluating classifiers",
    )
    parser.add_argument(
        "--papila-contours-dir",
        type=Path,
        default=Path("Papila/ExpertsSegmentations/Contours"),
        help="Directory containing Papila contour text files",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()

    if not any(
        [
            args.train_unet_seg,
            args.train_clf,
            args.eval,
            args.eval_seg,
            args.export_backbone,
        ]
    ):
        raise SystemExit(
            "Specify at least one action: --train-unet-seg, --train-clf, --eval, --eval-seg, or --export-backbone"
        )

    if args.train_unet_seg:
        train_unet_segmenter(args)

    if args.train_clf:
        train_classifier(args)

    if args.eval:
        evaluate(args)

    if args.eval_seg:
        evaluate_segmentation(args)

    if args.export_backbone:
        pre = ensure_preprocessing()
        seg = _load_segmentation(pre, args)
        clf = _load_classifier(pre, seg, args)
        out_path = args.export_backbone
        out_path.parent.mkdir(parents=True, exist_ok=True)
        torch.save(clf.extract_backbone().state_dict(), out_path)
        print(f"Backbone weights exported to {out_path}")


if __name__ == "__main__":
    main()
