"""
Detector MCP Server (FastMCP)

Tool:
        detect(det_prompt, image_path) -> {"mask_path": str, "meta": {...}}

Backends (selected via DETECTOR_BACKEND):
    grounded-sam-2 : external Grounded-SAM-2 repo inference producing a mask
    auto (default) : prefer grounded-sam-2 if GROUNDED_SAM2_DIR discovered, else fallback full-frame

Environment variables (grounded-sam-2):
    GROUNDED_SAM2_DIR             Path to cloned https://github.com/IDEA-Research/Grounded-SAM-2
    GROUNDED_SAM2_PYTHON          (optional) explicit python executable
    GROUNDED_SAM2_RUN             Override command template. Placeholders: {image} {prompt} {out}; $GROUNDED_SAM2_PYTHON substituted.
                                                                Example: "$GROUNDED_SAM2_PYTHON grounded_sam2_demo.py --image {image} --text '{prompt}' --out {out}"
    GROUNDED_SAM2_TIMEOUT_SECONDS Timeout (default 90)

If an adapter fails it returns an all-black mask with diagnostic metadata so callers never silently skip anonymization.

Original fallback: OpenAI Vision rectangle / full-frame has been superseded by modular adapters.
"""
from __future__ import annotations

from pathlib import Path
import sys
from typing import Dict, Any, Tuple, List
import os
import json
import subprocess
import time
import glob
import math
from datetime import datetime, timezone
import platform
import hashlib

# Defer PIL import to runtime inside the tool to avoid module import failure when Pillow isn't installed.

# Best-effort: load .env if present to pick up DETECTOR_BACKEND/GROUNDED_SAM2_DIR when server is run standalone
try:  # pragma: no cover - optional convenience
    from dotenv import load_dotenv  # type: ignore
    load_dotenv(override=False)
except Exception:
    pass


DATA_MASKS = Path("data") / "masks"  # Fallback, but prefer artifacts-based paths
DATA_MASKS.mkdir(parents=True, exist_ok=True)

ARTIFACT_LOGS_DIR = Path("artifacts") / "logs"

ARTIFACTS_TMP = Path("artifacts") / "tmp"
ARTIFACTS_TMP.mkdir(parents=True, exist_ok=True)

def _safe_mask_filename(ipath: Path, slug: str, max_stem: int = 40, max_slug: int = 50) -> Path:
    """Create a bounded-length, collision-resistant mask filename.

    Combines a shortened image stem and slug with a short SHA1 digest of
    the absolute image path + slug, ensuring filenames remain well under
    common filesystem limits (255 bytes per filename on ext4).
    
    Masks are saved to companion folder's masks/ subdirectory:
    - artifacts/{relative_parent}/{image_stem}/masks/ if image is in artifacts/
    - data/masks/ as fallback otherwise
    """
    try:
        stem = ipath.stem[:max_stem]
        # sanitize slug
        safe_slug = ''.join(c if (c.isalnum() or c in ("-", "_")) else "_" for c in slug.strip())
        if len(safe_slug) > max_slug:
            safe_slug = safe_slug[:max_slug]
        digest_src = f"{ipath.resolve()}|{slug}".encode("utf-8", errors="ignore")
        digest = hashlib.sha1(digest_src).hexdigest()[:10]
        filename = f"{stem}_{safe_slug}_{digest}_mask.png"
        # final safety guard to avoid edge cases
        if len(filename) > 120:
            filename = f"{stem[:max(10, max_stem-10)]}_{safe_slug[:max(10, max_slug-10)]}_{digest}_mask.png"
        
        # Determine mask directory: save to companion folder's masks/ if in artifacts, else use data/masks fallback
        mask_dir = DATA_MASKS  # fallback
        try:
            if "artifacts" in str(ipath.parent):
                # Image is in artifacts/{relative_parent}/{image}.png
                # Save masks to artifacts/{relative_parent}/{image_stem}/masks/
                companion_folder = ipath.parent / ipath.stem
                mask_dir = companion_folder / "masks"
                mask_dir.mkdir(parents=True, exist_ok=True)
        except Exception:
            pass  # fall back to DATA_MASKS
        
        return mask_dir / filename
    except Exception:
        # Fall back to a very compact name in worst case
        return DATA_MASKS / ("mask_" + hashlib.sha1(os.urandom(8)).hexdigest()[:10] + ".png")

def _log_output_local(image_path: Path, output_path: Path, meta: Dict[str, Any] | None = None, warn: str | None = None) -> None:
    """Append a JSONL entry to artifacts/logs/outputs.jsonl for traceability.

    This avoids cross-server calls; controlled by env DETECTOR_SKIP_LOG (set to '1' to disable).
    
    NOTE: Disabled by default to avoid cluttering artifacts/logs.
    """
    # Always skip logging to artifacts/logs to keep artifacts clean
    return
    
    # Original logging code below (disabled)
    try:
        if os.getenv("DETECTOR_SKIP_LOG"):
            return
        ARTIFACT_LOGS_DIR.mkdir(parents=True, exist_ok=True)
        entry: Dict[str, Any] = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "image": str(image_path),
            "location": str(Path(image_path).resolve()),
            "output": str(output_path),
            "output_location": str(Path(output_path).resolve()),
        }
        # Attach basic workstation info for traceability (similar to Auditor)
        try:
            entry["workstation"] = {
                "os": platform.platform(),
                "python": platform.python_version(),
                "machine": platform.machine(),
                "processor": platform.processor() or platform.machine(),
                "gpu": {"available": False},
            }
        except Exception:
            pass
        if meta:
            entry["meta"] = meta
        if warn:
            entry["warning"] = warn
        with (ARTIFACT_LOGS_DIR / "outputs.jsonl").open("a", encoding="utf-8") as f:
            f.write(json.dumps(entry) + "\n")
    except Exception:
        # Swallow logging errors to keep the tool resilient
        pass

def _mask_rectangle(size: Tuple[int, int], bbox: Tuple[int, int, int, int] | None = None):
    from PIL import Image, ImageDraw  # type: ignore
    w, h = size
    img = Image.new("L", (w, h), 0)
    draw = ImageDraw.Draw(img)
    if bbox is None:
        draw.rectangle([(0, 0), (w - 1, h - 1)], fill=255)
    else:
        x, y, bw, bh = bbox
        x2 = max(0, min(w - 1, x + bw))
        y2 = max(0, min(h - 1, y + bh))
        x1 = max(0, min(w - 1, x))
        y1 = max(0, min(h - 1, y))
        draw.rectangle([(x1, y1), (x2, y2)], fill=255)
    return img


def _run_without_mcp() -> None:
    print(
        "Detector MCP server requires the 'mcp' package.\n"
        "Install with: pip install mcp\n"
        "Then run: python -m mcp.servers.detector_server"
    )


try:
    from mcp.server.fastmcp import FastMCP
except Exception:
    FastMCP = None  # type: ignore[misc,assignment]


if FastMCP is None:
    def main() -> None:  # noqa: D401
        _run_without_mcp()
else:
    app = FastMCP("DetectorServer")

    # VATEX support removed

    @app.tool()
    def detect(det_prompt: str, image_path: str, backend: str | None = None, grounded_dir: str | None = None) -> Dict[str, Any]:  # noqa: D401
        """Produce a binary mask PNG for the given open-vocabulary prompt and image.

        Returns
        -------
        {"mask_path": str, "meta": {...}} or {"mask_path": "", "error": str}
        """
        ipath = Path(image_path)
        if not ipath.exists():
            return {"mask_path": "", "error": f"image not found: {image_path}"}

        # Output path under artifacts/{relative_parent}/masks/ or data/masks/ — bounded-length filename
        slug = det_prompt.strip().replace(" ", "_").replace("/", "-")
        out_path = _safe_mask_filename(ipath, slug)

        # Allow direct backend / grounded_dir override via tool arguments (takes precedence over env)
        if grounded_dir:
            os.environ["GROUNDED_SAM2_DIR"] = grounded_dir
        # If backend param provided, sync into environment so later diagnostics reflect it
        if backend:
            os.environ["DETECTOR_BACKEND"] = backend
        backend = (backend or os.getenv("DETECTOR_BACKEND", "auto")).lower()
        # Capture early environment snapshot for diagnostics (added for troubleshooting backend selection)
        _early_diag = {
            "env_DETECTOR_BACKEND": os.getenv("DETECTOR_BACKEND"),
            "env_GROUNDED_SAM2_DIR": os.getenv("GROUNDED_SAM2_DIR"),
            "pid": os.getpid(),
            "exe": sys.executable,
            "module_file": __file__,
        }

        try:
            from PIL import Image  # type: ignore
        except Exception as e:
            return {"mask_path": "", "error": f"Pillow not available: {e}. Install with: pip install Pillow"}

        try:
            with Image.open(ipath) as im:
                w, h = im.size
        except Exception as e:
            return {"mask_path": "", "error": f"failed to read image: {e}"}

        meta: Dict[str, Any] = {}
        mask = None

        # Backend selection order
        tried: List[str] = []

        def _grounded_available() -> bool:
            gd = os.getenv("GROUNDED_SAM2_DIR")
            return bool(gd and Path(gd).exists())

        backend_normalized = backend.replace("_", "-")
        debug = bool(os.getenv("DETECTOR_DEBUG"))
        if debug:
            try:
                print(
                    f"[detector] requested_backend={backend} normalized={backend_normalized}",
                    f" grounded_selected={(backend_normalized in ('grounded-sam-2','groundedsam2','gsam2'))}",
                    f" GROUNDED_SAM2_DIR={os.getenv('GROUNDED_SAM2_DIR')}",
                    file=sys.stderr,
                    flush=True,
                )
            except Exception:
                pass

        # 1) Grounded-SAM-2 (explicit or auto preference)
        grounded_selected = backend_normalized in ("grounded-sam-2", "groundedsam2", "gsam2")
        auto_with_grounded = backend_normalized == "auto" and _grounded_available()
        grounded_attempted_meta = None
        if grounded_selected or auto_with_grounded:
            tried.append("grounded-sam-2")
            try:
                result = _detect_with_grounded_sam2(ipath, det_prompt, (w, h))
                if result is not None:
                    mask, meta = result
                    # Capture failure meta (meta contains error key) for diagnostics if it failed
                    if meta and isinstance(meta, dict) and meta.get("error"):
                        grounded_attempted_meta = meta
            except Exception as e:  # pragma: no cover - defensive
                grounded_attempted_meta = {"provider": "grounded-sam-2", "error": f"adapter_crash: {e}"}
                meta = grounded_attempted_meta

        # If user explicitly requested grounded-sam-2 and it failed, return with that meta; otherwise fall back to full-frame below.
        if mask is None and grounded_selected and grounded_attempted_meta is not None:
            mask = _mask_rectangle((w, h), None)
            meta = {**grounded_attempted_meta, "note": "grounded-sam-2 explicitly requested; no other backends enabled"}
            meta.setdefault("requested_backend", backend)
            meta.setdefault("tried", tried)
            if debug:
                try:
                    print("[detector] grounded-sam-2 failed; returning without alternative backend", file=sys.stderr, flush=True)
                except Exception:
                    pass

        # Final fallback: full-frame
        if mask is None:
            mask = _mask_rectangle((w, h), None)
            meta = {"provider": "fallback", "type": "full_frame", "reason": "no backend available", "tried": tried}
            if grounded_attempted_meta:
                meta["grounded_failure"] = grounded_attempted_meta
            meta.setdefault("requested_backend", backend)
            if debug:
                try:
                    print(f"[detector] using final fallback full-frame mask tried={tried}", file=sys.stderr, flush=True)
                except Exception:
                    pass

        # Always ensure diagnostic fields present
        if isinstance(meta, dict):
            meta.setdefault("requested_backend", backend)
            if "tried" not in meta:
                meta["tried"] = tried
            # Attach early env diagnostic snapshot (without overwriting user meta keys)
            if "diag_env" not in meta:
                try:
                    meta["diag_env"] = _early_diag
                except Exception:
                    pass
            # VATEX removed: no cross-backend inconsistency checks needed

        try:
            out_path.parent.mkdir(parents=True, exist_ok=True)
            mask.save(out_path, format="PNG")
        except Exception as e:
            return {"mask_path": "", "error": f"failed to save mask: {e}"}

        # Internal logging (JSONL) for traceability
        try:
            _log_output_local(ipath, out_path, meta)
        except Exception:
            pass

        return {"mask_path": str(out_path), "meta": meta}
        

    

    @app.tool()
    def segment_persons(image_path: str) -> Dict[str, Any]:  # noqa: D401
        """Detect, segment, and describe persons using YOLO.

        Returns
        -------
        {
            "instances": [
                {"det_prompt": str, "mask_path": str, "description": str, "meta": {...}}
            ],
            "meta": {"count": int, ...}
        }

        Notes
        -----
        - Segments persons via YOLO (Ultralytics) in a single pass.
        - For each detected person, generates a short neutral description from a crop.
        - Outputs are saved under artifacts/{relative_parent}/masks/ or data/masks/ with stable filenames.
        """
        ipath = Path(image_path)
        if not ipath.exists():
            return {"instances": [], "error": f"image not found: {image_path}"}

        try:
            from PIL import Image  # type: ignore
        except Exception as e:
            return {"instances": [], "error": f"Pillow not available: {e}. Install with: pip install Pillow"}

        try:
            with Image.open(ipath) as im:
                w, h = im.size
        except Exception as e:
            return {"instances": [], "error": f"failed to read image: {e}"}

        # 1) Run YOLO person segmentation once to get all person masks
        yolo_masks = _yolo_person_masks(ipath, (w, h))
        try:
            debug = bool(os.getenv("DETECTOR_DEBUG"))
        except Exception:
            debug = False
        if debug:
            try:
                import sys as _sys
                print(f"[Detector] segment_persons ipath={ipath}", file=_sys.stderr, flush=True)
                print(f"[Detector] segment_persons yolo_masks_count={len(yolo_masks)}", file=_sys.stderr, flush=True)
                if not yolo_masks:
                    model_hint = os.getenv("YOLO_MODEL_PATH") or os.getenv("YOLO_MODEL", "yolov8m-seg.pt")
                    device_hint = os.getenv("YOLO_DEVICE")
                    conf_hint = os.getenv("YOLO_CONF", "0.25")
                    print(f"[Detector] YOLO hints: model={model_hint} device={device_hint} conf={conf_hint}", file=_sys.stderr, flush=True)
            except Exception:
                pass
        if not yolo_masks:
            return {"instances": [], "meta": {"count": 0, "image": str(ipath), "source": "yolo", "warning": "no persons"}}

        # 2) Serialize masks and build instances list
        instances: List[Dict[str, Any]] = []
        try:
            # Sort left-to-right for deterministic ordering
            yolo_masks_sorted = sorted(yolo_masks, key=lambda m: float(m.get("xc", 0)))
        except Exception:
            yolo_masks_sorted = yolo_masks

        describe = bool(os.getenv("DETECTOR_DESCRIBE_PERSONS"))
        for i, item in enumerate(yolo_masks_sorted):
            try:
                mask_img = item.get("mask")
                bbox = item.get("bbox")  # (x1,y1,x2,y2)
                meta = item.get("meta", {}) or {}
                if mask_img is None:
                    continue
                slug = f"person_{i:02d}"
                out_path = _safe_mask_filename(ipath, slug)
                out_path.parent.mkdir(parents=True, exist_ok=True)
                mask_img.save(out_path, format="PNG")

                # Also save to artifacts/tmp
                tmp_path = ARTIFACTS_TMP / out_path.name
                tmp_path.parent.mkdir(parents=True, exist_ok=True)
                mask_img.save(tmp_path, format="PNG")

                # Optional brief description via GPT (best-effort)
                description: str | None = None
                desc_meta: Dict[str, Any] | None = None
                if describe:
                    try:
                        description, desc_meta = _describe_person(ipath, bbox)
                        if desc_meta:
                            meta = {**meta, "describe": desc_meta}
                    except Exception:
                        pass
                if not description:
                    description = "person"

                inst = {
                    "det_prompt": "person",
                    "mask_path": str(out_path),
                    "description": description,
                    "meta": {
                        **meta,
                        "bbox": list(bbox) if isinstance(bbox, (list, tuple)) else bbox,
                        "type": "person",
                        "provider": meta.get("provider", "ultralytics"),
                    },
                }
                instances.append(inst)
                try:
                    _log_output_local(ipath, out_path, inst.get("meta"))
                except Exception:
                    pass
            except Exception:
                # Continue building other instances even if one fails
                continue

        return {"instances": instances, "meta": {"count": len(instances), "image": str(ipath), "source": "yolo"}}

    @app.tool()
    def detect_license_plates(image_path: str) -> Dict[str, Any]:  # noqa: D401
        """Detect license plates and return rectangle masks per detection.

        Returns
        -------
        {
            "instances": [
                {"det_prompt": "license plate", "mask_path": str, "description": "license plate", "meta": {...}}
            ],
            "meta": {"count": int, ...}
        }

        Notes
        -----
        - Uses Ultralytics YOLO detection model for license plates.
        - Model path is taken from env YOLO_LP_MODEL_PATH or YOLO_LP_MODEL.
        - If model isn't available, returns empty result with a warning.
        """
        ipath = Path(image_path)
        if not ipath.exists():
            return {"instances": [], "error": f"image not found: {image_path}"}

        try:
            from PIL import Image  # type: ignore
        except Exception as e:
            return {"instances": [], "error": f"Pillow not available: {e}. Install with: pip install Pillow"}

        # Discover model path
        model_path: str | None = os.getenv("YOLO_LP_MODEL_PATH") or os.getenv("YOLO_LP_MODEL")
        if not model_path:
            # Best-effort fallbacks inside repo
            candidates = [
                "models/license-plate-detection/lp_smallobj_1280/weights/best.pt"
            ]
            for c in candidates:
                if Path(c).exists():
                    model_path = c
                    break

        warn: str | None = None
        try:
            with Image.open(ipath) as im:
                w, h = im.size
        except Exception as e:
            return {"instances": [], "error": f"failed to read image: {e}"}

        try:
            from ultralytics import YOLO  # type: ignore
        except Exception as e:
            return {
                "instances": [],
                "meta": {"count": 0, "image": str(ipath)},
                "error": f"Ultralytics not available: {e}. Install with: pip install ultralytics",
            }

        if not model_path or not Path(model_path).exists():
            warn = f"license plate model not found (YOLO_LP_MODEL_PATH/YOLO_LP_MODEL). Checked: {model_path or 'none'}"
            return {
                "instances": [],
                "meta": {"count": 0, "image": str(ipath), "warning": warn},
            }

        # Inference settings
        # Lower default confidence and higher default imgsz for better small plate detection
        try:
            conf = float(os.getenv("YOLO_LP_CONF", "0.05"))
        except Exception:
            conf = 0.15
        imgsz_env = os.getenv("YOLO_LP_IMGSZ")
        try:
            # Default to 2560 for better small object detection (2x training resolution at 1280)
            imgsz = int(imgsz_env) if imgsz_env else 2560
        except Exception:
            imgsz = 2560
        device = os.getenv("YOLO_DEVICE")  # reuse same device hint as person seg
        
        # Log license plate detection parameters
        try:
            import sys as _sys
            print(f"[Detector] === LICENSE PLATE DETECTION PARAMETERS ===", file=_sys.stderr, flush=True)
            print(f"[Detector] YOLO_LP_CONF: {conf}", file=_sys.stderr, flush=True)
            print(f"[Detector] YOLO_LP_IMGSZ: {imgsz}px", file=_sys.stderr, flush=True)
        except Exception:
            pass
        
        # IOU threshold for NMS (Non-Maximum Suppression)
        # WHY 0.3: Lower threshold for small objects to avoid merging nearby plates
        # Scientific Rationale: Small objects benefit from aggressive NMS to catch multiple plates
        iou_threshold = float(os.getenv("YOLO_LP_IOU", "0.3"))
        
        # Enable advanced detection features for better small object recall
        # - Lower IOU threshold to catch multiple nearby plates
        # - Agnostic NMS disabled for single-class detection
        # - Half precision (FP16) for faster inference without quality loss

        try:
            model = YOLO(model_path)
            results = model.predict(
                source=str(ipath),
                conf=conf,
                imgsz=imgsz,
                iou=iou_threshold,  # Better handling of nearby plates
                device=device,
                verbose=False,
                agnostic_nms=False,  # Class-specific NMS (only one class: license plate)
                max_det=300,  # Increased from default 300 for dense traffic scenes
            )
        except Exception as e:
            return {"instances": [], "meta": {"count": 0, "image": str(ipath), "warning": f"YOLO inference failed: {e}"}}

        instances: List[Dict[str, Any]] = []
        tmp_dir = Path("artifacts") / "tmp"
        tmp_dir.mkdir(parents=True, exist_ok=True)

        # Build instances with rectangle masks
        try:
            for ri, r in enumerate(results or []):
                boxes = getattr(r, "boxes", None)
                if boxes is None:
                    continue
                try:
                    import torch  # type: ignore
                    xyxy = boxes.xyxy.cpu().numpy() if hasattr(boxes.xyxy, "cpu") else boxes.xyxy
                    confs = boxes.conf.cpu().numpy() if hasattr(boxes.conf, "cpu") else boxes.conf
                except Exception:
                    xyxy = boxes.xyxy
                    confs = getattr(boxes, "conf", None)
                if xyxy is None:
                    continue
                for bi, b in enumerate(xyxy):
                    try:
                        x1, y1, x2, y2 = [int(max(0, v)) for v in b[:4]]
                        bw = max(1, x2 - x1)
                        bh = max(1, y2 - y1)
                        bbox = (x1, y1, bw, bh)
                        # Create rectangle mask
                        mask_img = _mask_rectangle((w, h), bbox)
                        out_path = _safe_mask_filename(ipath, f"license_plate_{ri}_{bi}")
                        out_path.parent.mkdir(parents=True, exist_ok=True)
                        mask_img.save(out_path, format="PNG")
                        score = float(confs[bi]) if confs is not None else None
                        meta = {
                            "score": score,
                            "bbox": [x1, y1, bw, bh],
                            "model": str(model_path),
                            "provider": "ultralytics",
                            "type": "license_plate",
                            "conf": conf,
                        }
                        inst = {
                            "det_prompt": "license plate",
                            "mask_path": str(out_path),
                            "description": "license plate",
                            "meta": meta,
                        }
                        instances.append(inst)
                        # Local log for traceability
                        try:
                            _log_output_local(ipath, out_path, meta)
                        except Exception:
                            pass
                    except Exception:
                        continue
        except Exception:
            # If results parsing fails, return what we have
            pass

        return {
            "instances": instances,
            "meta": {
                "count": len(instances),
                "image": str(ipath),
                "model": str(model_path),
                **({"warning": warn} if warn else {}),
            },
        }

    @app.tool()
    def detect_traffic_signs(image_path: str) -> Dict[str, Any]:  # noqa: D401
        """Detect traffic signs using YOLO-TS in separate environment and return rectangle masks per detection.

        Returns
        -------
        {
            "instances": [
                {"det_prompt": "traffic sign", "mask_path": str, "description": str, "meta": {...}}
            ],
            "meta": {"count": int, ...}
        }

        Notes
        -----
        - Uses YOLO-TS model in separate conda environment (yolots).
        - Model path is taken from env YOLO_TS_MODEL_PATH.
        - Creates rectangular masks from bounding boxes.
        """
        import subprocess
        import json
        
        ipath = Path(image_path)
        if not ipath.exists():
            return {"instances": [], "error": f"image not found: {image_path}"}

        try:
            from PIL import Image  # type: ignore
        except Exception as e:
            return {"instances": [], "error": f"Pillow not available: {e}. Install with: pip install Pillow"}

        try:
            with Image.open(ipath) as im:
                w, h = im.size
        except Exception as e:
            return {"instances": [], "error": f"failed to read image: {e}"}

        # Get inference script path
        script = Path(__file__).parent.parent.parent / "scripts" / "yolo_ts_inference.sh"
        if not script.exists():
            return {
                "instances": [],
                "meta": {"count": 0, "image": str(ipath), "warning": "yolo_ts_inference.sh not found"},
            }

        # Run YOLO-TS inference in separate environment
        try:
            result = subprocess.run(
                [str(script), str(ipath)],
                capture_output=True,
                text=True,
                timeout=30,
                check=True
            )
            data = json.loads(result.stdout)
        except subprocess.TimeoutExpired:
            return {"instances": [], "meta": {"count": 0, "image": str(ipath), "error": "inference timeout"}}
        except subprocess.CalledProcessError as e:
            return {"instances": [], "meta": {"count": 0, "image": str(ipath), "error": f"inference failed: {e.stderr}"}}
        except json.JSONDecodeError as e:
            return {"instances": [], "meta": {"count": 0, "image": str(ipath), "error": f"invalid JSON response: {e}"}}
        except Exception as e:
            return {"instances": [], "meta": {"count": 0, "image": str(ipath), "error": str(e)}}

        # Convert YOLO-TS output to detector format with masks
        instances: List[Dict[str, Any]] = []
        yolo_instances = data.get("instances", [])
        
        for i, det in enumerate(yolo_instances):
            try:
                # Extract bbox (x, y, width, height format from YOLO-TS)
                bbox = det.get("bbox")
                if not bbox or len(bbox) < 4:
                    continue
                
                x, y, width, height = bbox[:4]
                x1, y1 = int(x), int(y)
                x2, y2 = int(x + width), int(y + height)
                
                # Ensure bbox is within image bounds
                x1 = max(0, min(x1, w))
                y1 = max(0, min(y1, h))
                x2 = max(0, min(x2, w))
                y2 = max(0, min(y2, h))
                
                if x2 <= x1 or y2 <= y1:
                    continue  # Invalid bbox
                
                # Create rectangle mask
                bbox_tuple = (x1, y1, x2 - x1, y2 - y1)
                mask_img = _mask_rectangle((w, h), bbox_tuple)
                
                # Save mask
                class_name = det.get("class_name", "unknown")
                slug = f"traffic_sign_{class_name}_{i:02d}"
                out_path = _safe_mask_filename(ipath, slug)
                out_path.parent.mkdir(parents=True, exist_ok=True)
                mask_img.save(out_path, format="PNG")
                
                # Build instance metadata
                score = det.get("score")
                class_id = det.get("class_id")
                description = det.get("description", f"traffic sign ({class_name})")
                
                meta = {
                    "score": score,
                    "bbox": [x1, y1, x2 - x1, y2 - y1],
                    "class_id": class_id,
                    "class_name": class_name,
                    "provider": "yolo-ts",
                    "type": "traffic_sign",
                    "model": data.get("meta", {}).get("model", "YOLO-TS"),
                }
                
                inst = {
                    "det_prompt": "traffic sign",
                    "mask_path": str(out_path),
                    "description": description,
                    "meta": meta,
                }
                instances.append(inst)
                
                # Local log for traceability
                try:
                    _log_output_local(ipath, out_path, meta)
                except Exception:
                    pass
                    
            except Exception as e:
                # Continue building other instances even if one fails
                continue

        return {
            "instances": instances,
            "meta": {
                "count": len(instances),
                "image": str(ipath),
                "source": "yolo-ts",
                **{k: v for k, v in data.get("meta", {}).items() if k not in ["count", "image"]},
            },
        }

    def _yolo_person_masks(ipath: Path, size_wh: Tuple[int, int]) -> List[Dict[str, Any]]:
        """Run YOLO segmentation for class 'person' and return list of masks + metadata.

        Returns list of dicts with keys:
          - mask: PIL.Image (L), same size as input
          - bbox: (x1, y1, x2, y2)
          - xc: x-center (for L->R sorting)
          - meta: score, class, model info
        """
        w, h = size_wh
        debug = bool(os.getenv("DETECTOR_DEBUG"))
        try:
            from PIL import Image  # type: ignore
            import numpy as np  # type: ignore
            # Configure Ultralytics to download/cache weights under repo models/
            from ultralytics import YOLO  # type: ignore
            try:
                from ultralytics import settings as _ul_settings  # type: ignore
                _repo_root = Path(__file__).resolve().parents[2]
                _weights_dir = _repo_root / "models"
                _weights_dir.mkdir(parents=True, exist_ok=True)
                _ul_settings.update({"weights_dir": str(_weights_dir)})
            except Exception:
                # Non-fatal: fallback to Ultralytics defaults
                pass
            if debug:
                try:
                    import ultralytics as _ul
                    import torch as _torch  # type: ignore
                    import sys as _sys
                    print(
                        f"[Detector][YOLO] versions: ultralytics={getattr(_ul, '__version__', 'unknown')} torch={getattr(_torch, '__version__', 'unknown')} cuda_avail={_torch.cuda.is_available() if hasattr(_torch, 'cuda') else False}",
                        file=_sys.stderr,
                        flush=True,
                    )
                except Exception:
                    pass
        except Exception as e:
            # Can't run YOLO; return empty instances list
            if debug:
                try:
                    import sys as _sys
                    print(f"[Detector][YOLO] import failed: {e}", file=_sys.stderr, flush=True)
                except Exception:
                    pass
            return []

        # Choose a segmentation-capable model; fall back to detection-only if seg not available
        # Default to YOLOv8x-seg (extra-large) for best person detection accuracy
        # User can override with YOLO_MODEL env var (e.g., yolov8m-seg.pt for speed)
        model_name = os.getenv("YOLO_MODEL", "yolov8x-seg.pt")
        model_path = os.getenv("YOLO_MODEL_PATH")  # optional absolute/relative path to weights
        # If a name (not an explicit path) is provided, direct the download into repo models/
        try:
            _repo_root = Path(__file__).resolve().parents[2]
            _weights_dir = _repo_root / "models"
            _weights_dir.mkdir(parents=True, exist_ok=True)
            if not model_path:
                # Keep only the filename part in case a variant like "ultralytics/yolov8m-seg.pt" is passed
                _fname = Path(str(model_name)).name
                model_path = str((_weights_dir / _fname).resolve())
        except Exception:
            pass
        device = os.getenv("YOLO_DEVICE")  # e.g. "0" or "cpu"
        if debug:
            try:
                import sys as _sys
                from pathlib import Path as _P
                mp = _P(model_path) if model_path else None
                print(
                    f"[Detector][YOLO] config: model_name={model_name} model_path={model_path} exists={mp.exists() if mp else None} device={device}",
                    file=_sys.stderr,
                    flush=True,
                )
            except Exception:
                pass
        try:
            model = YOLO(model_path or model_name)
        except Exception:
            # as last resort, try a detection model (no seg), emit bbox masks
            try:
                try:
                    _repo_root = Path(__file__).resolve().parents[2]
                    _weights_dir = _repo_root / "models"
                    _weights_dir.mkdir(parents=True, exist_ok=True)
                    _det_path = str((_weights_dir / "yolov8m.pt").resolve())
                except Exception:
                    _det_path = "yolov8m.pt"
                model = YOLO(_det_path)
                if debug:
                    try:
                        import sys as _sys
                        print("[Detector][YOLO] seg model load failed; fell back to detection model yolov8m.pt", file=_sys.stderr, flush=True)
                    except Exception:
                        pass
            except Exception:
                if debug:
                    try:
                        import sys as _sys
                        print("[Detector][YOLO] both seg and det model load failed", file=_sys.stderr, flush=True)
                    except Exception:
                        pass
                return []

        # Lower confidence threshold for better recall (detect more people)
        # Higher recall is critical for anonymization - better to over-detect than miss people
        try:
            conf = float(os.getenv("YOLO_CONF", "0.10"))  # Lowered from 0.25 -> 0.15 -> 0.10
        except Exception:
            conf = 0.10
        
        # Always log YOLO parameters for paper accuracy
        try:
            import sys as _sys
            print(f"[Detector] === YOLO PERSON DETECTION PARAMETERS ===", file=_sys.stderr, flush=True)
            print(f"[Detector] YOLO_CONF: {conf}", file=_sys.stderr, flush=True)
            print(f"[Detector] Image size: {max(w, h)}px", file=_sys.stderr, flush=True)
        except Exception:
            pass
        
        if debug:
            try:
                import sys as _sys
                print(f"[Detector][YOLO] predict: conf={conf} imgsz={max(w, h)} classes=[0]", file=_sys.stderr, flush=True)
            except Exception:
                pass
        # Allow override of image size via env to control speed/accuracy tradeoff
        try:
            imgsz_env = os.getenv("YOLO_IMGSZ")
            imgsz = int(imgsz_env) if imgsz_env else max(w, h)
        except Exception:
            imgsz = max(w, h)
        # Enable advanced detection features for best results
        # - Lower IOU threshold to catch overlapping/occluded people
        # - Test-Time Augmentation (TTA) for robustness (if enabled via env)
        # - Multi-scale detection by trying multiple image sizes
        
        iou_threshold = float(os.getenv("YOLO_IOU", "0.4"))  # Lower = better overlap handling
        enable_tta = bool(os.getenv("YOLO_TTA"))  # Set YOLO_TTA=1 for test-time augmentation
        
        try:
            # Primary detection with optimized parameters
            results = model.predict(
                source=str(ipath),
                classes=[0],  # person class
                imgsz=imgsz,
                conf=conf,
                iou=iou_threshold,  # Better handling of overlapping detections
                verbose=False,
                device=device,
                augment=enable_tta,  # TTA improves detection at cost of speed
                agnostic_nms=False,  # Class-specific NMS
                max_det=300,  # Allow more detections (default is 300)
            )
        except Exception as e:
            # Fallback: try with more aggressive settings
            try:
                low_conf = max(0.05, conf * 0.5)
                if debug:
                    try:
                        import sys as _sys
                        print(f"[Detector][YOLO] first predict failed: {e}; retrying with conf={low_conf}", file=_sys.stderr, flush=True)
                    except Exception:
                        pass
                results = model.predict(
                    source=str(ipath),
                    classes=[0],
                    imgsz=imgsz,
                    conf=low_conf,
                    iou=iou_threshold,
                    verbose=False,
                    device=device,
                    max_det=300,
                )
            except Exception:
                if debug:
                    try:
                        import sys as _sys
                        print("[Detector][YOLO] predict failed even after lowering conf", file=_sys.stderr, flush=True)
                    except Exception:
                        pass
                return []

        out: List[Dict[str, Any]] = []
        if debug:
            try:
                import sys as _sys
                print(f"[Detector][YOLO] results_count={len(results)} imgsz_used={imgsz}", file=_sys.stderr, flush=True)
            except Exception:
                pass
        for r in results:
            try:
                # r.masks may be None for detection-only models
                if getattr(r, "masks", None) is not None and r.masks is not None and len(r.masks) > 0:
                    if debug:
                        try:
                            import sys as _sys
                            print(f"[Detector][YOLO] masks found: {len(r.masks)} boxes={len(r.boxes) if getattr(r,'boxes',None) is not None else 'n/a'}", file=_sys.stderr, flush=True)
                        except Exception:
                            pass
                    for seg, box, score, cls in zip(r.masks.data, r.boxes.xyxy, r.boxes.conf, r.boxes.cls):
                        # Use lower threshold for mask to capture more detail
                        m = (seg.cpu().numpy() > 0.3).astype("uint8") * 255  # Lowered from 0.5
                        pil = Image.fromarray(m)
                        if pil.size != (w, h):
                            pil = pil.resize((w, h), resample=Image.NEAREST)
                        
                        # Check if mask is empty - if so, fall back to bbox mask
                        mask_array = np.array(pil)
                        used_bbox_fallback = False
                        if mask_array.sum() == 0:
                            # Empty segmentation mask - use bounding box as fallback
                            if debug:
                                try:
                                    import sys as _sys
                                    print(f"[Detector][YOLO] WARNING: Empty segmentation mask detected, falling back to bbox mask", file=_sys.stderr, flush=True)
                                except Exception:
                                    pass
                            from PIL import ImageDraw  # type: ignore
                            x1, y1, x2, y2 = [int(v) for v in box.tolist()]
                            pil = Image.new("L", (w, h), 0)
                            d = ImageDraw.Draw(pil)
                            d.rectangle([(x1, y1), (x2, y2)], fill=255)
                            mask_array = np.array(pil)
                            used_bbox_fallback = True
                        
                        # Improved post-processing: close small gaps, smooth edges, optionally dilate
                        # DO NOT erode - that causes missed edges and ghosting
                        # SKIP aggressive post-processing for bbox fallback masks to prevent removal
                        try:
                            import cv2 as _cv2  # type: ignore
                            
                            if not used_bbox_fallback:
                                # Only apply morphological ops to real segmentation masks
                                # Morphological closing to fill small gaps within person
                                kernel = _cv2.getStructuringElement(_cv2.MORPH_ELLIPSE, (5, 5))
                                mask_array = _cv2.morphologyEx(mask_array, _cv2.MORPH_CLOSE, kernel)
                            
                                # Optional: remove very small noise components (skip for bbox fallback)
                                num_labels, labels, stats, _ = _cv2.connectedComponentsWithStats(mask_array, connectivity=8)
                                if num_labels > 1:  # More than just background
                                    # Keep only components larger than 0.1% of image
                                    min_size = (w * h) * 0.001
                                    for label_id in range(1, num_labels):  # Skip background (0)
                                        if stats[label_id, _cv2.CC_STAT_AREA] < min_size:
                                            mask_array[labels == label_id] = 0
                            
                            # Optional dilation to expand mask slightly beyond person boundaries
                            # This helps capture context around the person for better inpainting
                            # Controlled by YOLO_MASK_DILATE_ITERATIONS (default: 3 iterations with 3x3 kernel)
                            # Apply to all masks (including bbox fallback)
                            try:
                                dilate_iterations = int(os.getenv("YOLO_MASK_DILATE_ITERATIONS", "3"))
                                if dilate_iterations > 0:
                                    dilate_kernel_size = int(os.getenv("YOLO_MASK_DILATE_KERNEL", "3"))
                                    dilate_kernel = _cv2.getStructuringElement(_cv2.MORPH_ELLIPSE, (dilate_kernel_size, dilate_kernel_size))
                                    mask_array = _cv2.dilate(mask_array, dilate_kernel, iterations=dilate_iterations)
                            except Exception:
                                pass  # Skip dilation if config invalid
                            
                            pil = Image.fromarray(mask_array)
                        except Exception:
                            # Fallback to basic scipy if cv2 unavailable
                            # Skip aggressive post-processing for bbox fallback masks
                            if not used_bbox_fallback:
                                try:
                                    from scipy import ndimage  # type: ignore
                                    mask_array = np.array(pil)
                                    # Only closing, no erosion
                                    mask_binary = mask_array > 0
                                    closed = ndimage.binary_closing(mask_binary, iterations=2)
                                    pil = Image.fromarray((closed.astype(np.uint8) * 255))
                                except Exception:
                                    pass  # Use original mask if post-processing fails
                        
                        x1, y1, x2, y2 = [int(v) for v in box.tolist()]
                        xc = (x1 + x2) / 2.0
                        
                        # Final safety check: if mask is still empty after all processing, recreate bbox mask
                        final_mask_array = np.array(pil)
                        if final_mask_array.sum() == 0:
                            if debug:
                                try:
                                    import sys as _sys
                                    print(f"[Detector][YOLO] CRITICAL: Mask empty after post-processing, recreating bbox mask", file=_sys.stderr, flush=True)
                                except Exception:
                                    pass
                            from PIL import ImageDraw  # type: ignore
                            pil = Image.new("L", (w, h), 0)
                            d = ImageDraw.Draw(pil)
                            d.rectangle([(x1, y1), (x2, y2)], fill=255)
                        
                        # Filter out very small or low-confidence detections
                        bbox_area = (x2 - x1) * (y2 - y1)
                        min_bbox_area = (w * h) * 0.0005  # 0.05% of image
                        score_val = float(score.item()) if hasattr(score, "item") else float(score)
                        
                        if bbox_area >= min_bbox_area:  # Only keep reasonable-sized detections
                            out.append({
                                "mask": pil,
                                "bbox": (x1, y1, x2, y2),
                                "xc": xc,
                                "meta": {
                                    "score": score_val,
                                    "class": int(cls.item()) if hasattr(cls, "item") else int(cls),
                                    "model": model_name,
                                    "bbox_area": bbox_area,
                                },
                            })
                else:
                    # Detection only: synthesize rectangular masks from boxes
                    from PIL import ImageDraw  # type: ignore
                    blank = Image.new("L", (w, h), 0)
                    if debug:
                        try:
                            import sys as _sys
                            print(f"[Detector][YOLO] no segmentation masks; using bbox fallback with {len(r.boxes) if getattr(r,'boxes',None) is not None else 'n/a'} boxes", file=_sys.stderr, flush=True)
                        except Exception:
                            pass
                    for box, score, cls in zip(r.boxes.xyxy, r.boxes.conf, r.boxes.cls):
                        x1, y1, x2, y2 = [int(v) for v in box.tolist()]
                        pil = Image.new("L", (w, h), 0)
                        d = ImageDraw.Draw(pil)
                        d.rectangle([(x1, y1), (x2, y2)], fill=255)
                        # NO erosion for bbox fallback - keep full detection area
                        xc = (x1 + x2) / 2.0
                        
                        # Filter out very small detections
                        bbox_area = (x2 - x1) * (y2 - y1)
                        min_bbox_area = (w * h) * 0.0005
                        score_val = float(score.item()) if hasattr(score, "item") else float(score)
                        
                        if bbox_area >= min_bbox_area:
                            out.append({
                                "mask": pil,
                                "bbox": (x1, y1, x2, y2),
                                "xc": xc,
                                "meta": {
                                    "score": score_val,
                                    "class": int(cls.item()) if hasattr(cls, "item") else int(cls),
                                    "model": model_name,
                                    "fallback": "bbox_mask",
                                    "bbox_area": bbox_area,
                                },
                            })
            except Exception:
                continue
        if debug:
            try:
                import sys as _sys
                print(f"[Detector][YOLO] persons_detected={len(out)}", file=_sys.stderr, flush=True)
            except Exception:
                pass
        return out

def _describe_person(ipath: Path, bbox: Tuple[int, int, int, int] | None) -> Tuple[str | None, Dict[str, Any] | None]:
    """Use vision model (Gemini/Ollama/OpenAI) to produce a brief anonymized person description from a crop.

    Returns (description, meta) where meta may include provider/model and any errors.
    """
    try:
        from PIL import Image  # type: ignore
        import io
        import base64
    except Exception as e:
        return None, {"error": f"Pillow import failed: {e}"}

    # Prepare crop
    try:
        with Image.open(ipath) as im:
            if bbox is not None:
                x1, y1, x2, y2 = bbox
                x1 = max(0, min(im.width - 1, int(x1)))
                y1 = max(0, min(im.height - 1, int(y1)))
                x2 = max(0, min(im.width, int(x2)))
                y2 = max(0, min(im.height, int(y2)))
                if x2 <= x1 + 1 or y2 <= y1 + 1:
                    crop = im.copy()
                else:
                    crop = im.crop((x1, y1, x2, y2))
            else:
                crop = im.copy()
            # Encode as PNG data URL
            buf = io.BytesIO()
            crop.save(buf, format="PNG")
            b64 = base64.b64encode(buf.getvalue()).decode("ascii")
            data_url = f"data:image/png;base64,{b64}"
    except Exception as e:
        return None, {"error": f"crop/encode failed: {e}"}

    system_msg = "You are describing a person for AI-based anonymization. Generate a SHORT description for Stable Diffusion inpainting.\n\n" \
"CRITICAL: Invent DIVERSE clothing colors AND brightness - DO NOT copy from the image!\n" \
"Colors (pick randomly): gray, beige, navy, white, black, brown, khaki, blue, red, green, yellow, pink, teal, burgundy, olive, charcoal, maroon, tan, cream, sage.\n" \
"Brightness modifiers (pick randomly): light, dark, bright, faded, vibrant, muted, pale, deep, pastel, bold.\n" \
"Example colors: 'light beige', 'dark navy', 'bright red', 'faded olive', 'deep burgundy', 'pale pink', 'vibrant teal'.\n\n" \
"Include: body build (slim/medium/heavy), pose (standing/walking/sitting), view (front/side/back), action (talking/walking/standing).\n\n" \
"SPECIAL CASE: If uniformed (police/fire/military/security), preserve uniform type and official colors.\n\n" \
"Format: 'A [build] person [action], [view] view, wearing [brightness] [color] [top] and [brightness] [color] [bottom]'\n" \
"Limit: 20-30 words maximum.\n\n" \
"Examples:\n" \
"1. Generic: {\"description\": \"A medium-build person walking, side view, wearing dark olive jacket and pale charcoal pants\"}\n" \
"2. Generic: {\"description\": \"A slim person standing, front view, wearing vibrant burgundy sweater and light khaki chinos\"}\n" \
"3. Generic: {\"description\": \"A heavy-build person sitting, side view, wearing faded maroon hoodie and deep black jeans\"}\n" \
"4. Officer: {\"description\": \"A police officer talking, facing forward, wearing navy police uniform with badge\"}\n\n" \
"Return ONLY valid JSON: {\"description\": \"your text here\"}"

    # Check which provider to use (priority: Gemini > Ollama > OpenAI)
    use_gemini = os.getenv("USE_GEMINI_VISION", "false").lower() in ("true", "1", "yes")
    # Only use Ollama if Gemini is not enabled
    use_ollama = (not use_gemini) and os.getenv("USE_OLLAMA_VISION", "true").lower() in ("true", "1", "yes")

    try:
        if use_gemini:
            # Google Gemini Vision via OpenAI-compatible endpoint
            try:
                from openai import OpenAI  # type: ignore
            except Exception as e:
                return None, {"provider": "gemini", "error": f"openai pkg missing: {e}"}
            
            api_key = os.getenv("GEMINI_API_KEY")
            if not api_key:
                return None, {"provider": "gemini", "error": "GEMINI_API_KEY not set"}
            
            # Use OpenAI-compatible endpoint for Gemini
            base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
            model = os.getenv("GEMINI_VISION_MODEL", "gemini-2.0-flash-exp")
            
            client = OpenAI(api_key=api_key, base_url=base_url)
            
            completion = client.chat.completions.create(
                model=model,
                temperature=0.8,
                response_format={"type": "json_object"},
                messages=[
                    {"role": "system", "content": system_msg},
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": "Describe this person with invented diverse clothing colors AND brightness modifiers as JSON."},
                            {"type": "image_url", "image_url": {"url": data_url}},
                        ],
                    },
                ],
                max_tokens=150,
            )
            content = completion.choices[0].message.content or "{}"
            data = json.loads(content)
            desc = (data.get("description") or "").strip()
            return (desc or None), {"provider": "gemini", "model": model}
            
        elif use_ollama:
            # Ollama Vision (Qwen2.5-VL)
            base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
            try:
                from openai import OpenAI  # type: ignore
            except Exception as e:
                return None, {"provider": "ollama", "error": f"openai pkg missing: {e}"}
            
            client = OpenAI(api_key="ollama", base_url=base_url)
            model = os.getenv("OLLAMA_VISION_MODEL", "qwen2.5vl:32b")
            
            completion = client.chat.completions.create(
                model=model,
                temperature=0.8,
                response_format={"type": "json_object"},
                messages=[
                    {"role": "system", "content": system_msg},
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": "Describe this person with invented diverse clothing colors AND brightness modifiers as JSON."},
                            {"type": "image_url", "image_url": {"url": data_url}},
                        ],
                    },
                ],
                max_tokens=150,
            )
            content = completion.choices[0].message.content or "{}"
            data = json.loads(content)
            desc = (data.get("description") or "").strip()
            return (desc or None), {"provider": "ollama", "model": model}
        else:
            # OpenAI Vision (fallback)
            try:
                from openai import OpenAI  # type: ignore
            except Exception as e:
                return None, {"provider": "openai", "error": f"openai pkg missing: {e}"}
            
            api_key = os.getenv("OPENAI_API_KEY")
            if not api_key:
                return None, {"provider": "openai", "error": "OPENAI_API_KEY not set"}
            
            client = OpenAI(api_key=api_key)
            model = os.getenv("OPENAI_ANALYZER_MODEL", "gpt-4o-mini")
            
            completion = client.chat.completions.create(
                model=model,
                temperature=0.8,
                response_format={"type": "json_object"},
                messages=[
                    {"role": "system", "content": system_msg},
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": "Describe this person with invented diverse clothing colors AND brightness modifiers as JSON."},
                            {"type": "image_url", "image_url": {"url": data_url}},
                        ],
                    },
                ],
                max_tokens=150,
            )
            content = completion.choices[0].message.content or "{}"
            data = json.loads(content)
            desc = (data.get("description") or "").strip()
            return (desc or None), {"provider": "openai", "model": model}
            
    except Exception as e:
        provider = "gemini" if use_gemini else ("ollama" if use_ollama else "openai")
        return None, {"provider": provider, "error": f"request failed: {e}"}
    

def _fetch_analyzer_person_instances(ipath: Path) -> List[Dict[str, Any]]:
    """Get person-like instances from Analyzer or log, filtered to people.

    Strategy:
    1) Try direct import and call of Analyzer MCP tool function classify_pii.
    2) Fallback: read latest matching entry from artifacts/logs/instances.jsonl.
    3) Filter to person-like entries via det_prompt or description heuristics.
    """
    instances: List[Dict[str, Any]] = []

    # Attempt direct import/call first to ensure fresh alignment
    try:
        from mcp.servers.analyzer_server import classify_pii  # type: ignore

        result = classify_pii(str(ipath))
        raw = result.get("instances") or []
        if isinstance(raw, dict) and isinstance(raw.get("instances"), list):
            raw = raw.get("instances", [])
        if isinstance(raw, list):
            instances = raw  # type: ignore[assignment]
    except Exception:
        # Ignore and try logs
        pass

    if not instances:
        try:
            log_path = ARTIFACT_LOGS_DIR / "instances.jsonl"
            if log_path.exists():
                want = _best_match_instances_from_log(log_path, ipath)
                if want:
                    # entries may be wrapped as {"instances": {"instances": [...]}}
                    if isinstance(want, dict) and isinstance(want.get("instances"), dict):
                        inner = want["instances"].get("instances")
                        if isinstance(inner, list):
                            instances = inner
                    elif isinstance(want, dict) and isinstance(want.get("instances"), list):
                        instances = want["instances"]  # type: ignore[assignment]
        except Exception:
            pass

    # Filter to persons
    people = [inst for inst in instances if _is_person_instance(inst)]
    return people


def _is_person_instance(inst: Dict[str, Any]) -> bool:
    """Heuristic: identify person-like entries by text.

    Checks det_prompt first, then description.
    """
    def has_person_words(text: str) -> bool:
        t = text.lower()
        # common person tokens
        tokens = [
            "person", "people", "human", "man", "woman", "girl", "boy",
            "figure", "adult", "child", "kid",
        ]
        return any(tok in t for tok in tokens)

    det = (inst.get("det_prompt") or "").strip()
    if det and has_person_words(det):
        return True
    desc = (inst.get("description") or "").strip()
    if desc and has_person_words(desc):
        return True
    return False


def _best_match_instances_from_log(log_path: Path, ipath: Path) -> Dict[str, Any] | None:
    """Return the last JSONL entry for this image from instances.jsonl.

    Matching is tolerant to path representation differences (relative vs absolute,
    separators, leading ./); it tries exact, then basename-only.
    """
    want_abs = str(ipath.resolve())
    want_rel = str(ipath)
    want_base = ipath.name
    best: Dict[str, Any] | None = None
    try:
        with log_path.open("r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    obj = json.loads(line)
                except Exception:
                    continue
                img_str = str(obj.get("image") or "")
                # Normalize slashes and leading ./
                img_norm = img_str.replace("\\", "/")
                want_abs_norm = want_abs.replace("\\", "/")
                want_rel_norm = want_rel.replace("\\", "/")
                if (
                    img_norm == want_abs_norm
                    or img_norm == want_rel_norm
                    or img_norm.lstrip("./") == want_rel_norm.lstrip("./")
                    or os.path.basename(img_norm) == want_base
                ):
                    best = obj
    except Exception:
        return None
    return best
def _auto_find_grounded_sam2_dir() -> str | None:
    """Attempt to locate a grounded-sam-2 checkout.

    Order:
        1. GROUNDED_SAM2_DIR env (absolute or relative)
        2. <repo_root>/models/Grounded-SAM-2
    """
    raw = os.getenv("GROUNDED_SAM2_DIR")
    if raw:
        try:
            # strip quotes and expand common shell vars like $PWD
            cleaned = raw.strip().strip('"\'')
            cleaned = os.path.expandvars(cleaned).replace("${PWD}", str(Path.cwd())).replace("$PWD", str(Path.cwd()))
            p = Path(cleaned)
            if not p.is_absolute():
                p = Path.cwd() / p
            if p.exists():
                return str(p.resolve())
        except Exception:
            pass
    try:
        repo_root = Path(__file__).resolve().parents[2]
        cand = repo_root / "models" / "Grounded-SAM-2"
        if cand.exists():
            return str(cand.resolve())
    except Exception:
        pass
    return None

def _detect_with_grounded_sam2(ipath: Path, det_prompt: str, size_wh: Tuple[int, int]):
    """Grounded-SAM-2 backend adapter.

    Strategy:
        - Build/run command to produce a mask PNG for the prompt.
        - Uses hardcoded conda run -n gsam2 python models/Grounded-SAM-2/inference.py by default
        - Prefers user-provided GROUNDED_SAM2_RUN template; placeholders {image} {prompt} {out}.
        - Always returns (mask, meta) even on failure (blank mask with diagnostics).

    Environment:
        GROUNDED_SAM2_DIR, GROUNDED_SAM2_PYTHON, GROUNDED_SAM2_RUN, GROUNDED_SAM2_TIMEOUT_SECONDS
    """
    import shlex
    import uuid
    from PIL import Image  # type: ignore

    def _blank():
        w, h = size_wh
        return Image.new("L", (w, h), 0)

    debug = bool(os.getenv("DETECTOR_DEBUG"))
    fail_reasons: List[str] = []

    root = os.getenv("GROUNDED_SAM2_DIR") or _auto_find_grounded_sam2_dir()
    if root and not Path(root).is_absolute():
        root = str(Path.cwd() / root)
    if not root or not Path(root).exists():
        fail_reasons.append("grounded_sam2_dir_missing")
        return _blank(), {"provider": "grounded-sam-2", "error": "config", "reasons": fail_reasons, "dir": root}

    timeout = int(os.getenv("GROUNDED_SAM2_TIMEOUT_SECONDS", "90"))
    run_tpl = os.getenv("GROUNDED_SAM2_RUN")
    # Normalize GROUNDED_SAM2_PYTHON: accept either an explicit interpreter, a conda env name, or an env root path
    raw_py = os.getenv("GROUNDED_SAM2_PYTHON")
    py_interp: str | None = None
    conda_env_name: str | None = None
    if raw_py:
        rp = raw_py.strip().strip('"\'')
        # If user supplied a path to a python executable, use it
        if Path(rp).is_file():
            py_interp = rp
        else:
            # If user provided an env root (contains bin/python), prefer that
            try:
                candidate = Path(rp) / "bin" / "python"
                if candidate.exists():
                    py_interp = str(candidate)
            except Exception:
                pass
            # If still not found and string looks like a simple name (no slash), treat as conda env name
            if py_interp is None and ("/" not in rp and "\\" not in rp):
                conda_env_name = rp

    tmp_dir = Path("artifacts") / "tmp"
    tmp_dir.mkdir(parents=True, exist_ok=True)
    # Use the expected output filename that matches your hardcoded call
    base = tmp_dir / f"manual_mask_{uuid.uuid4().hex}.png"
    # The inference script is expected to write exactly the file passed via --out
    # so we treat that as the expected mask path (some older variants produced *_mask.png)
    expected_mask = base

    # Command build - use your hardcoded structure as default
    if run_tpl:
        py = os.getenv("GROUNDED_SAM2_PYTHON") or "python"
        cmd_str = (
            run_tpl
            .replace("{image}", str(ipath.resolve()))
            .replace("{prompt}", det_prompt)
            .replace("{out}", str(base.resolve()))
        ).replace("$GROUNDED_SAM2_PYTHON", shlex.quote(py))
        cmd = shlex.split(cmd_str)
    else:
        # Use your hardcoded terminal call structure; prefer explicit interpreter if supplied
        if py_interp:
            cmd = [py_interp, "models/Grounded-SAM-2/inference.py", "--image", str(ipath.resolve()), "--text", det_prompt, "--out", str(base.resolve())]
        elif conda_env_name:
            cmd = ["conda", "run", "-n", conda_env_name, "python", "models/Grounded-SAM-2/inference.py", "--image", str(ipath.resolve()), "--text", det_prompt, "--out", str(base.resolve())]
        else:
            # Default: conda env name 'gsam2' (maintained for backwards compatibility)
            cmd = [
                "conda", "run", "-n", "gsam2", "python",
                "models/Grounded-SAM-2/inference.py",
                "--image", str(ipath.resolve()),
                "--text", det_prompt,
                "--out", str(base.resolve())
            ]

    t0 = time.time()
    try:
        proc = subprocess.run(
            cmd,
            cwd=str(Path.cwd()),  # Run from project root since using relative path to models/
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=timeout,
            check=False,
            text=True,
            env={**os.environ},
        )
    except subprocess.TimeoutExpired:
        meta = {"provider": "grounded-sam-2", "timeout": True, "warning": f"timed out after {timeout}s", "cmd": cmd}
        return _blank(), meta
    except Exception as e:
        fail_reasons.append(f"subprocess_error: {e}")
        return _blank(), {"provider": "grounded-sam-2", "error": "spawn", "reasons": fail_reasons, "cmd": cmd if 'cmd' in locals() else None}

    runtime = time.time() - t0
    stderr_tail = proc.stderr.strip().splitlines()[-1][:300] if proc.stderr else ""
    if proc.returncode != 0:
        fail_reasons.append(f"nonzero_exit_{proc.returncode}")

    # Heuristic: interactive loop scripts often print a tail inviting Ctrl-C and never write masks
    if stderr_tail.lower().startswith("press ctrl-c to quit") or "ctrl-c" in stderr_tail.lower():
        if "interactive_loop" not in fail_reasons:
            fail_reasons.append("interactive_loop")
            # Provide a specific hint only once
            if "interactive_hint" not in fail_reasons:
                fail_reasons.append("set_GROUNDED_SAM2_RUN_for_batch")
    if not expected_mask.exists():
        # Try relaxed discovery: any file with the same stem + 'mask' in tmp dir created recently
        alt_mask = None
        try:
            stem = base.stem
            candidates = list(base.parent.glob(f"{stem}*mask*.png"))
            if candidates:
                # pick most recent
                candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
                if candidates[0].exists():
                    alt_mask = candidates[0]
        except Exception:
            pass
        if alt_mask and alt_mask.exists():
            expected_mask = alt_mask
        else:
            fail_reasons.append("mask_file_missing")

    if fail_reasons:
        meta = {
            "provider": "grounded-sam-2",
            "error": "inference_failed",
            "reasons": fail_reasons,
            "cmd": cmd,
            "cwd": str(Path(root).resolve()),
            "runtime_sec": round(runtime, 3),
        }
        _script_val = locals().get("script")
        if _script_val is not None:
            meta["chosen_script"] = str(_script_val)
            _reason_val = locals().get("chosen_reason")
            if _reason_val is not None:
                meta["chosen_script_reason"] = _reason_val
        if stderr_tail:
            meta["stderr_tail"] = stderr_tail
        return _blank(), meta

    try:
        from PIL import Image  # type: ignore
        with Image.open(expected_mask) as m:
            m = m.convert("L")
            w, h = size_wh
            if m.size != (w, h):
                m = m.resize((w, h), resample=Image.NEAREST)
            meta = {
                "provider": "grounded-sam-2",
                "runtime_sec": round(runtime, 3),
                "cmd": cmd,
                "cwd": str(Path(root).resolve()),
            }
            _script_val = locals().get("script")
            if _script_val is not None:
                meta["chosen_script"] = str(_script_val)
                _reason_val = locals().get("chosen_reason")
                if _reason_val is not None:
                    meta["chosen_script_reason"] = _reason_val
            if stderr_tail:
                meta["stderr_tail"] = stderr_tail
            return m, meta
    except Exception as e:  # pragma: no cover
        fail_reasons.append(f"postprocess_error: {e}")
        return _blank(), {"provider": "grounded-sam-2", "error": "postprocess", "reasons": fail_reasons}

def main() -> None:  # noqa: D401
    print("[DetectorServer] Starting (transport=stdio)…", file=sys.stderr, flush=True)
    try:
        app.run(transport="stdio")
    except BrokenPipeError:
        # Client closed connection during shutdown - this is expected behavior
        pass
    except KeyboardInterrupt:
        # Graceful shutdown on Ctrl+C
        pass
    except BaseException as e:
        # Check if it's an ExceptionGroup containing BrokenPipeError
        if hasattr(e, '__cause__') or hasattr(e, '__context__'):
            cause = getattr(e, '__cause__', None) or getattr(e, '__context__', None)
            if isinstance(cause, BrokenPipeError):
                pass  # Expected shutdown
                return
        # Check for ExceptionGroup with BrokenPipeError
        if e.__class__.__name__ in ('ExceptionGroup', 'BaseExceptionGroup'):
            try:
                exceptions = getattr(e, 'exceptions', [])
                if any(isinstance(ex, BrokenPipeError) or 
                       'BrokenPipeError' in str(type(ex)) or
                       'Broken pipe' in str(ex) for ex in exceptions):
                    pass  # Expected shutdown
                    return
            except Exception:
                pass
        print(f"[DetectorServer] Unexpected error: {e}", file=sys.stderr, flush=True)
        raise


if __name__ == "__main__":
    main()
