"""Autogen-based multi-agent orchestration for anonymization pipeline.

Refactored architecture: each domain-specific agent owns ONLY its intended
MCP-backed tools. Cross-agent coordination happens via an Autogen group chat
managed by an orchestrator agent (which itself has no direct tools and must
request other agents to act).

Agents & tool scope:
    OrchestratorAgent    : (no tools) plans sequence, coordinates other agents, checks duplicates
    AuditorAgent         : classify_pii, audit_output, log_output  
    Generative Agent     : anonymize_and_inpaint
    UserProxyAgent       : executes registered Python functions that wrap MCP calls

Note: Detection and blurring operations (persons, license plates, traffic signs)
are executed deterministically in Phase 1 without LLM orchestration for speed
and reliability.

Intended conversation flow:
 1) Phase 1 (Deterministic): Persons detected/inpainted, plates detected/blurred, signs detected/masked
 2) Phase 2 (Agentic): Auditor Agent runs classify_pii to find indirect PII (text, signs, logos, etc.)
 3) Generative Agent anonymizes and inpaints classified PII instances
 4) Auditor Agent runs audit_output to verify anonymization  
 5) If audit fails: Orchestrator checks duplicates, Generative Agent re-processes remaining PII
 6) Auditor Agent runs log_output to record results
 7) Final JSON summary is deterministically constructed from the per-run PipelineContext.

Design goals:
    - Strict separation of concerns: agents cannot call tools outside their scope.
    - Deterministic summary independent of LLM free-form text.
    - Easily extensible: add new specialized agents without changing others.

Run example:
    python -m mcp.clients.agents.main data/example.jpeg

Environment:
    USE_GEMINI: Set to 'true' to use Google Gemini models (paid, cloud).
    USE_OPENAI: Set to 'true' to use OpenAI models (paid, cloud).
    AUTOGEN_MODEL: Model name (default: 'gemini-2.0-flash-exp' for Gemini, 'gpt-4o-mini' for OpenAI, 'mistral-small:24b' for Ollama).
    GEMINI_API_KEY: Required when USE_GEMINI=true.
    OPENAI_API_KEY: Required when USE_OPENAI=true.
    OLLAMA_BASE_URL: Ollama API endpoint (default: 'http://localhost:11434/v1').
    AUTOGEN_TIMEOUT: Timeout in seconds (default: 300).
    SKIP_PHASE2: Set to '1' or 'true' for persons-only mode (skips indirect PII detection).
    SKIP_PHASE1: Set to '1' or 'true' for phase2-only mode (skips person/plate detection, only runs agentic PII classification).

Future extensions:
    - Add reviewer/validator agent for schema enforcement.
    - Introduce memory or vector store for large multi-image batches.
"""
from __future__ import annotations

import asyncio
import json
import os
import base64
import copy
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import openai
from dataclasses import dataclass
import argparse
import shutil
import ollama
import re
import sys

import numpy as np
from time import perf_counter  # added

# Sentinel token used by orchestrator to signal group chat termination
COMPLETION_SENTINEL = "PIPELINE COMPLETE"

# Autogen core
try:
    import autogen
    from autogen import AssistantAgent, ConversableAgent, UserProxyAgent
except Exception as e:  # pragma: no cover
    raise SystemExit(
        "Autogen not installed. Install with: pip install pyautogen -- Find dependency in requirements.txt"
    )

"""This file now acts as the sole orchestrator.

Implements lightweight stdio tool calls inline.
"""

# Lightweight MCP stdio invocation
try:
    from mcp.client.session import ClientSession  # type: ignore
    from mcp.client.stdio import stdio_client, StdioServerParameters  # type: ignore
except Exception as _e:  # pragma: no cover
    ClientSession = None  # type: ignore
    stdio_client = None  # type: ignore
    StdioServerParameters = None  # type: ignore

@dataclass
class ServerSpec:
    module: str


ANALYZER = ServerSpec("mcp.servers.analyzer_server")
DETECTOR = ServerSpec("mcp.servers.detector_server")
INPAINTER = ServerSpec("mcp.servers.inpainter_server")
AUDITOR = ServerSpec("mcp.servers.auditor_server")

# Per-run context replacing global state
from mcp.clients.agents.context import PipelineContext

# Canonical output path that should always hold the most recent processed image
# Note: This will be set dynamically in main() to artifacts/{relative_parent}/{input_stem}{input_suffix}
CANONICAL_OUTPUT = Path("artifacts") / "output.jpeg"  # Default, overridden in main()

_TRUTHY_VALUES = {"1", "true", "yes", "on"}


def _truthy_env(name: str, default: bool = False) -> bool:
    """Return True if the environment variable is truthy."""
    val = os.getenv(name)
    if val is None:
        return default
    lowered = val.strip().lower()
    if lowered in _TRUTHY_VALUES:
        return True
    if lowered in {"0", "false", "no", "off"}:
        return False
    return default


def _instance_signature(data: Dict[str, Any]) -> str:
    """Build a stable signature from instance attributes (no instance_id)."""
    if not isinstance(data, dict):
        return ""
    description = (data.get("description") or "").strip().lower()
    det_prompt = (data.get("det_prompt") or "").strip().lower()
    bbox = data.get("bbox")
    if isinstance(bbox, (list, tuple)) and len(bbox) == 4:
        try:
            bbox_key = ",".join(str(int(x)) for x in bbox)
        except Exception:
            bbox_key = ",".join(str(x) for x in bbox)
    else:
        bbox_key = "none"
    return f"{description}|{det_prompt}|{bbox_key}"


def _instance_key(data: Dict[str, Any]) -> str:
    """Key used for instance record storage (prefers assigned instance_id)."""
    if not isinstance(data, dict):
        return ""
    inst_id = data.get("instance_id")
    if inst_id:
        return str(inst_id)
    return _instance_signature(data)


def _update_instance_record(ctx: PipelineContext, info: Dict[str, Any], source: Optional[str] = None) -> None:
    """Merge instance info into context records for deterministic logging."""
    if not isinstance(info, dict):
        return
    key = _instance_key(info)
    if not key:
        return

    record = ctx.instance_records.get(key)
    if record is None:
        record = {}
        ctx.instance_records[key] = record

    # Core identifiers
    inst_id = info.get("instance_id")
    if inst_id:
        record["instance_id"] = inst_id
    if description := info.get("description"):
        record["description"] = description
    if det_prompt := info.get("det_prompt"):
        record["det_prompt"] = det_prompt
    if info.get("bbox") is not None:
        record["bbox"] = info.get("bbox")

    # Operational metadata
    if mask_path := info.get("mask_path"):
        record["mask_path"] = mask_path
    if output_path := info.get("output_path"):
        record["output_path"] = output_path
    if backend := info.get("backend"):
        record["backend"] = backend

    controlnets = info.get("controlnets")
    if isinstance(controlnets, list):
        rec_cn = record.setdefault("controlnets", [])
        for cn in controlnets:
            if cn not in rec_cn:
                rec_cn.append(cn)

    status = info.get("status")
    if status:
        record["status"] = status
        history = record.setdefault("status_history", [])
        history.append(status)
    if reason := info.get("reason"):
        record["reason"] = reason
        reasons = record.setdefault("reasons", [])
        if reason not in reasons:
            reasons.append(reason)
    if error_msg := info.get("error"):
        errors = record.setdefault("errors", [])
        if error_msg not in errors:
            errors.append(error_msg)

    if source:
        sources = record.setdefault("sources", [])
        if source not in sources:
            sources.append(source)


def _compute_iou(bbox1: List[int], bbox2: List[int]) -> float:
    """Compute Intersection over Union (IoU) between two bounding boxes.

    Args:
        bbox1: [x, y, w, h] format
        bbox2: [x, y, w, h] format

    Returns:
        IoU score between 0.0 and 1.0
    """
    if not (isinstance(bbox1, (list, tuple)) and len(bbox1) == 4):
        return 0.0
    if not (isinstance(bbox2, (list, tuple)) and len(bbox2) == 4):
        return 0.0

    try:
        # Convert [x, y, w, h] to [x1, y1, x2, y2]
        x1_1, y1_1, w1, h1 = [float(v) for v in bbox1]
        x2_1, y2_1 = x1_1 + w1, y1_1 + h1

        x1_2, y1_2, w2, h2 = [float(v) for v in bbox2]
        x2_2, y2_2 = x1_2 + w2, y1_2 + h2

        # Calculate intersection
        x_left = max(x1_1, x1_2)
        y_top = max(y1_1, y1_2)
        x_right = min(x2_1, x2_2)
        y_bottom = min(y2_1, y2_2)

        if x_right < x_left or y_bottom < y_top:
            return 0.0

        intersection_area = (x_right - x_left) * (y_bottom - y_top)

        # Calculate union
        bbox1_area = w1 * h1
        bbox2_area = w2 * h2
        union_area = bbox1_area + bbox2_area - intersection_area

        if union_area <= 0:
            return 0.0

        iou = intersection_area / union_area
        return max(0.0, min(1.0, iou))  # Clamp to [0, 1]
    except Exception:
        return 0.0


def _check_iou_overlap_with_processed(ctx: PipelineContext, bbox: List[int], iou_threshold: float = 0.3) -> bool:
    """Check if a bbox significantly overlaps with any already-processed instance.

    Args:
        ctx: Pipeline context with instance records
        bbox: Bounding box to check [x, y, w, h]
        iou_threshold: IoU threshold for considering overlap (default: 0.3)

    Returns:
        True if bbox overlaps significantly with any processed instance
    """
    if not bbox or not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
        return False

    # Check against all successfully processed instances
    for record in ctx.instance_records.values():
        # Only check against instances that were successfully processed (not skipped or errored)
        if record.get("status") not in ["processed", "detected"]:
            continue

        record_bbox = record.get("bbox")
        if not record_bbox:
            continue

        iou = _compute_iou(bbox, record_bbox)
        if iou >= iou_threshold:
            desc = record.get("description", "unknown")
            print(f"[IoU Check] High overlap (IoU={iou:.2f}) with processed instance: {desc}", file=sys.stderr, flush=True)
            return True

    return False


def _normalize_instances(ctx: PipelineContext, instances: Any, source: str) -> List[Dict[str, Any]]:
    """Assign stable instance_ids and update context records."""
    normalized: List[Dict[str, Any]] = []
    if not isinstance(instances, list):
        return normalized

    for raw in instances:
        if not isinstance(raw, dict):
            continue
        inst = copy.deepcopy(raw)
        signature = _instance_signature(inst)
        inst_id = ctx.instance_id_map.get(signature)
        if not inst_id:
            ctx.instance_counter += 1
            inst_id = f"pii-{ctx.instance_counter:04d}"
            ctx.instance_id_map[signature] = inst_id
        inst["instance_id"] = inst_id
        normalized.append(inst)
        _update_instance_record(ctx, inst, source=source)

    return normalized

# Duplicate detection thresholds (configurable via environment variables)
# These control spatial overlap thresholds for duplicate filtering
# Semantic matching (Stage 1) uses LLM-as-judge with no numerical threshold
DUPLICATE_THRESHOLD_GENERATIVE = float(os.getenv("DUPLICATE_THRESHOLD_GENERATIVE", "0.7"))
DUPLICATE_THRESHOLD_ORCHESTRATOR = float(os.getenv("DUPLICATE_THRESHOLD_ORCHESTRATOR", "0.5"))

# Global reference to log file for cleanup
_LOG_FILE = None
_ORIGINAL_STDOUT = None
_ORIGINAL_STDERR = None


def _setup_terminal_logging(log_path: Path):
    """Set up terminal output logging to file."""
    global _LOG_FILE, _ORIGINAL_STDOUT, _ORIGINAL_STDERR
    
    log_path.parent.mkdir(parents=True, exist_ok=True)
    _LOG_FILE = open(log_path, 'w', encoding='utf-8', buffering=1)
    _ORIGINAL_STDOUT = sys.stdout
    _ORIGINAL_STDERR = sys.stderr
    
    class TeeWriter:
        def __init__(self, *streams):
            self.streams = streams
        def write(self, text):
            for stream in self.streams:
                try:
                    stream.write(text)
                    stream.flush()
                except (BrokenPipeError, OSError):
                    # Silently ignore broken pipe errors
                    pass
                except Exception:
                    # Log other exceptions but don't crash
                    pass
        def flush(self):
            for stream in self.streams:
                try:
                    stream.flush()
                except (BrokenPipeError, OSError):
                    # Silently ignore broken pipe errors
                    pass
                except Exception:
                    # Log other exceptions but don't crash
                    pass
    
    sys.stdout = TeeWriter(_ORIGINAL_STDOUT, _LOG_FILE)
    sys.stderr = TeeWriter(_ORIGINAL_STDERR, _LOG_FILE)
    print(f"[log] Logging to: {log_path}", file=sys.stderr, flush=True)
    
    # Configure Python's logging module to ignore BrokenPipeError
    # This prevents errors when logging tries to write to closed pipes
    import logging
    
    class IgnoreBrokenPipeHandler(logging.StreamHandler):
        """Handler that ignores BrokenPipeError during emit/flush."""
        def emit(self, record):
            try:
                super().emit(record)
            except (BrokenPipeError, OSError):
                pass
            except Exception:
                self.handleError(record)
        
        def flush(self):
            try:
                super().flush()
            except (BrokenPipeError, OSError):
                pass
    
    # Replace existing handlers with our BrokenPipe-resistant version
    root_logger = logging.getLogger()
    for handler in root_logger.handlers[:]:
        if isinstance(handler, logging.StreamHandler):
            root_logger.removeHandler(handler)
            new_handler = IgnoreBrokenPipeHandler(handler.stream)
            new_handler.setLevel(handler.level)
            new_handler.setFormatter(handler.formatter)
            root_logger.addHandler(new_handler)


def _cleanup_terminal_logging():
    """Restore original stdout/stderr and close log file."""
    global _LOG_FILE, _ORIGINAL_STDOUT, _ORIGINAL_STDERR
    
    if _ORIGINAL_STDOUT is not None:
        sys.stdout = _ORIGINAL_STDOUT
    if _ORIGINAL_STDERR is not None:
        sys.stderr = _ORIGINAL_STDERR
    if _LOG_FILE is not None:
        _LOG_FILE.close()


def _collect_tmp_files(steps: List[Dict[str, Any]], final_output: Optional[str]) -> Tuple[List[Path], List[Path]]:
    """Return (mask_files, blur_files) excluding the final output."""
    masks, blurs = [], []
    final_p = Path(final_output).resolve() if final_output else None

    for s in steps or []:
        # Collect mask files
        mp = s.get("mask_path")
        if mp and isinstance(mp, str):
            mp_p = Path(mp)
            if mp_p.is_file() and "artifacts" in str(mp_p.parent) and "masks" in str(mp_p.parent):
                masks.append(mp_p)
        
        # Collect blur files (excluding final output)
        op = s.get("output_path")
        if op and isinstance(op, str):
            op_p = Path(op)
            if (op_p.is_file() and 
                "artifacts" in str(op_p.parent) and 
                ("_blurred" in op_p.name or "_anon" in op_p.name) and
                (not final_p or op_p.resolve() != final_p)):
                blurs.append(op_p)

    return masks, blurs


def _cleanup_tmp(masks: List[Path], blurs: List[Path]) -> Dict[str, Any]:
    """Delete temp files and return summary."""
    deleted = {"masks": 0, "blurs": 0, "errors": []}
    
    for p in masks:
        try:
            p.unlink(missing_ok=True)
            deleted["masks"] += 1
        except Exception as e:
            deleted["errors"].append({"file": str(p), "error": str(e)})
    
    for p in blurs:
        try:
            p.unlink(missing_ok=True)
            deleted["blurs"] += 1
        except Exception as e:
            deleted["errors"].append({"file": str(p), "error": str(e)})
    
    return deleted


def _crop_image_region(image_path: str, bbox: Tuple[int, int, int, int], output_path: str) -> bool:
    """Crop image to specified bounding box.
    
    Args:
        image_path: Path to source image
        bbox: (x, y, width, height) in pixels
        output_path: Where to save cropped image
        
    Returns:
        True if successful, False otherwise
    """
    try:
        from PIL import Image
        img = Image.open(image_path)
        x, y, w, h = bbox
        # Convert to PIL crop coordinates (left, top, right, bottom)
        cropped = img.crop((x, y, x + w, y + h))
        Path(output_path).parent.mkdir(parents=True, exist_ok=True)
        cropped.save(output_path)
        return True
    except Exception as e:
        print(f"[scout] Failed to crop image: {e}", file=sys.stderr, flush=True)
        return False

def _map_mask_to_full_image(crop_mask_path: str, crop_bbox: Tuple[int, int, int, int], full_image_size: Tuple[int, int], output_path: str) -> bool:
    """Map a mask from cropped coordinates back to full image coordinates.
    
    Args:
        crop_mask_path: Path to mask in crop coordinates
        crop_bbox: (x, y, width, height) of crop in full image
        full_image_size: (width, height) of full image
        output_path: Where to save the mapped mask
        
    Returns:
        True if successful, False otherwise
    """
    try:
        from PIL import Image
        crop_mask = Image.open(crop_mask_path).convert('L')
        x, y, w, h = crop_bbox
        full_w, full_h = full_image_size
        
        # Create blank full-size mask
        full_mask = Image.new('L', (full_w, full_h), 0)
        
        # Paste crop mask at correct position
        full_mask.paste(crop_mask, (x, y))
        
        # Ensure the mask is strictly constrained to the bbox area (clip any potential overflow)
        full_mask_array = np.array(full_mask)
        # Create a bbox mask
        bbox_mask = np.zeros((full_h, full_w), dtype=np.uint8)
        bbox_mask[y:y+h, x:x+w] = 255
        # Apply the bbox mask
        full_mask_array = np.where(bbox_mask > 0, full_mask_array, 0)
        
        # Convert back to PIL image
        full_mask = Image.fromarray(full_mask_array)
        
        Path(output_path).parent.mkdir(parents=True, exist_ok=True)
        full_mask.save(output_path)
        return True
    except Exception as e:
        print(f"[scout] Failed to map mask to full image: {e}", file=sys.stderr, flush=True)
        return False


def _add_mask_to_combined_pii_mask(mask_path: str, companion_folder: Path, image_path: str) -> None:
    """Immediately add a mask to the combined PII mask file.
    
    This function adds individual PII masks to a combined mask as they are created,
    ensuring masks are not lost even if processing is interrupted.
    
    Args:
        mask_path: Path to the individual mask to add
        companion_folder: Path to the companion folder containing masks/
        image_path: Path to the original input image (for dimensions)
    """
    from PIL import Image
    import numpy as np
    
    try:
        # Get input image dimensions
        img = Image.open(image_path).convert('RGB')
        img_w, img_h = img.size
        
        # Define combined PII mask path
        input_stem = Path(image_path).stem
        masks_dir = companion_folder / "masks"
        masks_dir.mkdir(parents=True, exist_ok=True)
        pii_mask_path = masks_dir / f"{input_stem}_pii_mask.png"
        
        # Load existing combined mask or create new one
        if pii_mask_path.exists():
            combined_mask = Image.open(pii_mask_path).convert('L')
            combined_arr = np.array(combined_mask) > 127  # Convert to boolean
        else:
            combined_arr = np.zeros((img_h, img_w), dtype=bool)
        
        # Load new mask
        new_mask = Image.open(mask_path).convert('L')
        
        # Resize if needed
        if new_mask.size != (img_w, img_h):
            new_mask = new_mask.resize((img_w, img_h), Image.NEAREST)
        
        new_arr = np.array(new_mask) > 127  # Convert to boolean
        
        # Combine using bitwise OR
        combined_arr |= new_arr
        
        # Save combined mask
        combined_mask_uint8 = (combined_arr.astype(np.uint8) * 255)
        combined_mask_img = Image.fromarray(combined_mask_uint8, mode='L')
        combined_mask_img.save(pii_mask_path)
        
        # Calculate coverage
        masked_pixels = combined_arr.sum()
        total_pixels = combined_arr.size
        coverage_pct = 100 * masked_pixels / total_pixels if total_pixels > 0 else 0
        
        print(f"[PII Mask] ✓ Added {Path(mask_path).name} to combined PII mask ({masked_pixels:,} pixels, {coverage_pct:.2f}% coverage)", file=sys.stderr, flush=True)
        
    except Exception as e:
        print(f"[PII Mask] ERROR adding mask to combined PII mask: {e}", file=sys.stderr, flush=True)
        import traceback
        traceback.print_exc()


def _mask_is_empty(mask_path: str) -> bool:
    """Check if a binary mask image is effectively empty (all black)."""
    try:
        from PIL import Image
        p = Path(mask_path)
        if not p.exists() or p.stat().st_size <= 0:
            return True
        arr = np.asarray(Image.open(p).convert("L"))
        # Require at least 64 nonzero pixels to consider mask valid
        return int((arr > 0).sum()) < 64
    except Exception:
        return True  # Treat errors as empty to trigger retry

# Removed _is_semantically_duplicate - unused, redundant with _batch_check_semantic_duplicates


# ============================================================================
# DUPLICATE DETECTION - Thresholds Documentation
# ============================================================================
# 
# The pipeline uses TWO-STAGE duplicate detection:
#
# STAGE 1 - SEMANTIC MATCHING (No numerical threshold):
#    - Uses Qwen2.5:32b LLM as a binary judge
#    - Returns yes/no decision: "Is this the same object?"
#    - Implemented in: _batch_check_semantic_duplicates()
#
# STAGE 2 - SPATIAL OVERLAP (Context-dependent thresholds):
#    - Orchestrator Agent: DUPLICATE_THRESHOLD_ORCHESTRATOR (default 0.5 = 50%)
#      → Matches paper specification for audit refinement loop
#    - Generative Agent: DUPLICATE_THRESHOLD_GENERATIVE (default 0.7 = 70%)
#      → More conservative filtering during indirect PII processing
#    - Implemented in: _is_spatially_aware_duplicate() and filter_residual_instances_tool()
#    - Configurable via .env file
#
# Both semantic AND spatial checks must pass for duplicate flagging.
# This prevents false positives (different objects at same location) and
# ensures multiple instances of same object type in different locations are processed.
# ============================================================================


# def _batch_check_semantic_duplicates(
#     new_descriptions: List[str],
#     inpainted_descriptions: List[str],
# ) -> List[Tuple[bool, Optional[str]]]:
#     """
#     Batch check multiple new descriptions against existing inpainted descriptions
#     using a single LLM call for efficiency.

#     Args:
#         new_descriptions: List of new descriptions to check
#         inpainted_descriptions: List of already-inpainted descriptions

#     Returns:
#         List of (is_duplicate, matching_description) tuples, one for each new description
#     """
#     if not new_descriptions or not inpainted_descriptions:
#         return [(False, None)] * len(new_descriptions)

#     # Filter out placeholder keys like "person_0"
#     valid_descriptions = [
#         d for d in inpainted_descriptions if not d.startswith("person_")
#     ]
#     if not valid_descriptions:
#         return [(False, None)] * len(new_descriptions)

#     # Build a structured prompt for batch duplicate decision
#     existing_list = "\n".join(
#         f"{i+1}. {desc}" for i, desc in enumerate(valid_descriptions)
#     )

#     new_list = "\n".join(
#         f"{i+1}. {desc}" for i, desc in enumerate(new_descriptions)
#     )

#     prompt = f"""
# You are a semantic duplicate detector for batch processing.

# For EACH new description below, determine if it represents the same object or content as any of the existing descriptions.

# Consider that descriptions may have minor variations in wording, spelling, specific numbers/text, or formatting, but if they clearly refer to the same physical object or content in the image, they should be considered duplicates.

# Return a JSON array where each element corresponds to a new description (in order):
# [{{"is_duplicate": true, "matching_index": <1-based index of matching existing description>}}, ...]

# If a new description is not a duplicate of any existing one, use {{"is_duplicate": false}}

# NEW descriptions to check:
# {new_list}

# EXISTING descriptions:
# {existing_list}

# Output only a valid JSON array.
# """

#     try:
#         print(f"[similarity] Batch checking {len(new_descriptions)} new descriptions against {len(valid_descriptions)} existing descriptions", file=sys.stderr, flush=True)
#         response = ollama.chat(
#             model="qwen2.5:32b",
#             messages=[{"role": "user", "content": prompt}],
#         )

#         content = response["message"]["content"].strip()
#         print(f"[similarity] Batch LLM response: {content[:200]}...", file=sys.stderr, flush=True)

#         # Parse JSON response
#         import json
#         try:
#             results = json.loads(content)
#         except json.JSONDecodeError:
#             print(f"[warn] Could not parse batch LLM output as JSON:\n{content}", file=sys.stderr)
#             return [(False, None)] * len(new_descriptions)

#         if not isinstance(results, list) or len(results) != len(new_descriptions):
#             print(f"[warn] Invalid batch response format or length mismatch: expected {len(new_descriptions)}, got {len(results) if isinstance(results, list) else 'non-list'}", file=sys.stderr)
#             return [(False, None)] * len(new_descriptions)

#         # Process results
#         output = []
#         for i, result in enumerate(results):
#             if not isinstance(result, dict) or "is_duplicate" not in result:
#                 print(f"[warn] Invalid result format for item {i+1}: {result}", file=sys.stderr)
#                 output.append((False, None))
#                 continue

#             is_duplicate = result["is_duplicate"]
#             if is_duplicate:
#                 matching_index = result.get("matching_index")
#                 if isinstance(matching_index, int) and 1 <= matching_index <= len(valid_descriptions):
#                     match_desc = valid_descriptions[matching_index - 1]
#                     print(f"[similarity] '{new_descriptions[i]}' is duplicate of '{match_desc}' (index: {matching_index})", file=sys.stderr, flush=True)
#                     output.append((True, match_desc))
#                 else:
#                     print(f"[warn] Invalid matching_index for item {i+1}: {matching_index}", file=sys.stderr)
#                     output.append((False, None))
#             else:
#                 output.append((False, None))

#         return output

#     except Exception as e:
#         print(f"[error] Batch Ollama call failed: {e}", file=sys.stderr, flush=True)
#         return [(False, None)] * len(new_descriptions)

# def _is_spatially_aware_duplicate(description: str, mask_path: str, ctx: PipelineContext, overlap_threshold: float = None) -> tuple[bool, Optional[str]]:
#     """Check if a description+mask combination is a duplicate of an already-inpainted instance.
    
#     This is spatially aware: multiple instances of the same category (e.g., "house number")
#     are allowed if they're in different locations. Only exact duplicates (same semantic
#     meaning AND significant spatial overlap) are flagged.
    
#     Note: Semantic matching is done via LLM (Qwen2.5:32b) as a binary yes/no decision,
#     not via numerical threshold. The LLM is called separately before this function.
    
#     Args:
#         description: The new description to check
#         mask_path: Path to the mask for this instance
#         ctx: Pipeline context with inpainted_description_mask_pairs
#         overlap_threshold: Spatial overlap threshold (default from DUPLICATE_THRESHOLD_GENERATIVE)
        
#     Returns:
#         (is_duplicate, matching_description) tuple
#     """
#     # Use environment variable default if not specified
#     if overlap_threshold is None:
#         overlap_threshold = DUPLICATE_THRESHOLD_GENERATIVE
#     for inpainted_desc, inpainted_mask in ctx.inpainted_description_mask_pairs:
#         # Skip person tracking keys
#         if inpainted_desc.startswith("person_"):
#             continue
        
#         # Semantically similar - now check spatial overlap
#         overlap = _calculate_mask_overlap(mask_path, inpainted_mask)
        
#         if overlap >= overlap_threshold:
#             # Both semantically similar AND spatially overlapping = true duplicate
#             print(f"[duplicate] '{description}' is duplicate of '{inpainted_desc}' (spatial: {overlap:.3f})", file=sys.stderr, flush=True)
#             return True, inpainted_desc

#     return False, None


def _calculate_mask_overlap(mask_path1: str, mask_path2: str) -> float:
    """Calculate overlap ratio between two masks (0.0 to 1.0)."""
    try:
        from PIL import Image
        mask1 = Image.open(mask_path1).convert('L')
        mask2 = Image.open(mask_path2).convert('L')
        
        if mask1.size != mask2.size:
            mask2 = mask2.resize(mask1.size, Image.Resampling.LANCZOS)
        
        arr1 = np.asarray(mask1) > 127
        arr2 = np.asarray(mask2) > 127
        
        mask1_pixels = arr1.sum()
        if mask1_pixels == 0:
            return 0.0
        
        overlap_pixels = (arr1 & arr2).sum()
        return float(overlap_pixels) / float(mask1_pixels)
    except Exception:
        return 0.0


def _calculate_bbox_mask_overlap(bbox: list, mask_path: str) -> float:
    """Calculate overlap between a bbox and a mask (0.0 to 1.0).
    
    Args:
        bbox: [x_min, y_min, x_max, y_max] or [x_min, y_min, width, height]
        mask_path: Path to mask file
        
    Returns:
        Overlap ratio (intersection / bbox_area)
    """
    try:
        from PIL import Image
        
        # Load mask
        mask = Image.open(mask_path).convert('L')
        mask_arr = np.asarray(mask) > 127
        
        # Parse bbox - detect format (x_min, y_min, x_max, y_max) vs (x_min, y_min, width, height)
        x_min, y_min, val3, val4 = bbox
        
        # Heuristic: if val3 < x_min or val4 < y_min, it's likely (width, height)
        if val3 < x_min or val4 < y_min:
            # Format: (x_min, y_min, width, height)
            width, height = val3, val4
            x_max = x_min + width
            y_max = y_min + height
        else:
            # Format: (x_min, y_min, x_max, y_max)
            x_max, y_max = val3, val4
        
        # Clamp to image bounds
        img_h, img_w = mask_arr.shape
        x_min = max(0, min(x_min, img_w))
        x_max = max(0, min(x_max, img_w))
        y_min = max(0, min(y_min, img_h))
        y_max = max(0, min(y_max, img_h))
        
        # Calculate bbox area
        bbox_width = x_max - x_min
        bbox_height = y_max - y_min
        bbox_area = bbox_width * bbox_height
        
        if bbox_area == 0:
            return 0.0
        
        # Extract mask region corresponding to bbox
        mask_region = mask_arr[int(y_min):int(y_max), int(x_min):int(x_max)]
        
        # Count overlap pixels
        overlap_pixels = mask_region.sum()
        
        return float(overlap_pixels) / float(bbox_area)
    except Exception as e:
        print(f"[warn] Failed to calculate bbox-mask overlap: {e}", file=sys.stderr, flush=True)
        return 0.0


def _subtract_masks_from_mask(mask_path: str, masks_to_subtract: List[str], output_path: str) -> bool:
    """Subtract multiple masks from a base mask and persist the result."""
    try:
        from PIL import Image

        base_mask = Image.open(mask_path).convert('L')
        base_arr = np.asarray(base_mask) > 127

        for subtract_mask_path in masks_to_subtract:
            if not Path(subtract_mask_path).exists():
                continue
            subtract_mask = Image.open(subtract_mask_path).convert('L')
            if subtract_mask.size != base_mask.size:
                subtract_mask = subtract_mask.resize(base_mask.size, Image.Resampling.LANCZOS)
            subtract_arr = np.asarray(subtract_mask) > 127
            base_arr = base_arr & ~subtract_arr

        if base_arr.sum() < 64:  # guard against empty masks
            return False

        result_img = Image.fromarray((base_arr * 255).astype(np.uint8), mode='L')
        Path(output_path).parent.mkdir(parents=True, exist_ok=True)
        result_img.save(output_path)
        return True
    except Exception as e:
        print(f"[MaskOps] Failed to subtract masks: {e}", file=sys.stderr, flush=True)
        return False


def _mask_overlaps_inpainted_areas(mask_path: str, inpainted_masks: List[str], threshold: float = 0.5) -> bool:
    """Check if mask overlaps existing inpainted masks beyond the threshold."""
    if not inpainted_masks:
        return False

    try:
        from PIL import Image

        new_mask = Image.open(mask_path).convert('L')
        new_mask_arr = np.asarray(new_mask) > 127
        new_mask_pixels = new_mask_arr.sum()

        if new_mask_pixels == 0:
            print(f"[Overlap] New mask {Path(mask_path).name} has 0 pixels, skipping overlap check", file=sys.stderr, flush=True)
            return False

        combined_mask_arr = np.zeros(new_mask_arr.shape, dtype=bool)
        valid_masks_count = 0

        for inpainted_mask_path in inpainted_masks:
            if not Path(inpainted_mask_path).exists():
                continue
            try:
                inpainted_mask = Image.open(inpainted_mask_path).convert('L')
                if inpainted_mask.size != new_mask.size:
                    inpainted_mask = inpainted_mask.resize(new_mask.size, Image.Resampling.LANCZOS)
                inpainted_mask_arr = np.asarray(inpainted_mask) > 127
                combined_mask_arr = combined_mask_arr | inpainted_mask_arr
                valid_masks_count += 1
            except Exception as exc:
                print(f"[Overlap] Warning: Failed to load inpainted mask {inpainted_mask_path}: {exc}", file=sys.stderr, flush=True)
                continue

        overlap_pixels = (new_mask_arr & combined_mask_arr).sum()
        overlap_ratio = float(overlap_pixels) / float(new_mask_pixels)
        combined_pixels = combined_mask_arr.sum()

        print(
            f"[Overlap] Mask {Path(mask_path).name}: {new_mask_pixels} pixels, combined: {combined_pixels} pixels ({valid_masks_count} masks), overlap: {overlap_pixels} pixels ({overlap_ratio:.1%})",
            file=sys.stderr,
            flush=True,
        )

        return overlap_ratio >= threshold

    except Exception as e:
        print(f"[Overlap] Error checking mask overlap: {e}", file=sys.stderr, flush=True)
        return False


def _extract_detection_prompt(description: str) -> str:
    """Extract key detection terms from region description."""
    desc_lower = description.lower()

    if any(term in desc_lower for term in ["text", "writing", "letters", "words"]):
        return "text ."
    if any(term in desc_lower for term in ["logo", "brand", "emblem"]):
        return "logo ."
    if any(term in desc_lower for term in ["sticker", "decal"]):
        return "sticker ."
    if any(term in desc_lower for term in ["sign", "placard", "notice"]):
        return "sign ."
    if any(term in desc_lower for term in ["license", "plate", "registration"]):
        return "license plate ."
    if any(term in desc_lower for term in ["poster", "advertisement"]):
        return "poster ."

    words = description.split()[:3]
    return " ".join(words) + " ."

async def call_tool(ctx: PipelineContext, spec: ServerSpec, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
    """Call a tool on the given MCP server spec (one-shot stdio invocation)."""
    if ClientSession is None or stdio_client is None or StdioServerParameters is None:
        return {"error": "MCP client SDK not available"}
    
    params = StdioServerParameters(
        command=sys.executable,
        args=["-m", spec.module],
        env=os.environ.copy(),
    )
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            res = await session.call_tool(name, arguments)
    
    return res


def _unwrap_tool_result(res: Any) -> Dict[str, Any]:
    """Extract JSON dict from MCP tool result."""
    if isinstance(res, dict):
        return res
    
    try:
        content = getattr(res, "content", None)
        if not content:
            return {}
        
        for c in content:
            # Handle dict-style content
            if isinstance(c, dict):
                if c.get("type") in ("json", "application/json"):
                    data = c.get("json") or c.get("data") or c.get("value")
                    if isinstance(data, dict):
                        return data
                elif c.get("type") == "text" and isinstance(c.get("text"), str):
                    try:
                        parsed = json.loads(c.get("text"))
                        if isinstance(parsed, dict):
                            return parsed
                    except Exception:
                        pass
            # Handle object-style content
            else:
                if getattr(c, "type", None) in ("json", "application/json"):
                    data = getattr(c, "json", None) or getattr(c, "data", None)
                    if isinstance(data, dict):
                        return data
                elif getattr(c, "type", None) == "text":
                    txt = getattr(c, "text", None)
                    if isinstance(txt, str):
                        try:
                            parsed = json.loads(txt)
                            if isinstance(parsed, dict):
                                return parsed
                        except Exception:
                            pass
    except Exception:
        pass
    
    return {}


async def _classify(ctx: PipelineContext, image_path: str) -> Dict[str, Any]:
    res = await call_tool(ctx, ANALYZER, "classify_pii", {"image_path": image_path})
    return _unwrap_tool_result(res)


async def _detect(ctx: PipelineContext, image_path: str, det_prompt: str) -> Dict[str, Any]:
    # Synthesize a fallback prompt if a blank string sneaks through
    eff_prompt = (det_prompt or "").strip()
    if not eff_prompt:
        # Try to recover from the last known classified instances
        try:
            if ctx.instances:
                # Fallback to description if det_prompt is missing
                cand = (
                    (ctx.instances[0].get("description") or "")
                    if isinstance(ctx.instances[0], dict) else ""
                )
                eff_prompt = cand.strip()
        except Exception:
            pass
    # As a last resort, avoid empty prompt which some adapters treat as no-op
    if not eff_prompt:
        eff_prompt = "small text or logo ."
    # Ensure trailing period token for adapters expecting dot-terminated phrases
    if eff_prompt and not eff_prompt.rstrip().endswith("."):
        eff_prompt = eff_prompt.rstrip() + " ."
    args = {"det_prompt": eff_prompt, "image_path": image_path}
    res = await call_tool(ctx, DETECTOR, "detect", args)
    return _unwrap_tool_result(res)


async def _segment_persons(ctx: PipelineContext, image_path: str) -> Dict[str, Any]:
    res = await call_tool(ctx, DETECTOR, "segment_persons", {"image_path": image_path})
    return _unwrap_tool_result(res)

async def _detect_license_plates(ctx: PipelineContext, image_path: str) -> Dict[str, Any]:
    res = await call_tool(ctx, DETECTOR, "detect_license_plates", {"image_path": image_path})
    return _unwrap_tool_result(res)


async def _detect_traffic_signs(ctx: PipelineContext, image_path: str) -> Dict[str, Any]:
    res = await call_tool(ctx, DETECTOR, "detect_traffic_signs", {"image_path": image_path})
    return _unwrap_tool_result(res)


async def _inpaint(ctx: PipelineContext, image_path: str, mask_path: str, prompt: str, description: str = "") -> Dict[str, Any]:
    res = await call_tool(
        ctx,
        INPAINTER,
        "inpaint",
        {"image_path": image_path, "mask_path": mask_path, "prompt": prompt, "description": description},
    )
    return _unwrap_tool_result(res)


async def _batch_inpaint(ctx: PipelineContext, image_path: str, instances: list) -> Dict[str, Any]:
    """Batch inpaint multiple instances in one server call (avoids model reloading)."""
    res = await call_tool(
        ctx,
        INPAINTER,
        "batch_inpaint",
        {"image_path": image_path, "instances": instances},
    )
    return _unwrap_tool_result(res)


async def _blur(ctx: PipelineContext, image_path: str, mask_path: str, radius: float = 15) -> Dict[str, Any]:
    res = await call_tool(
        ctx,
        INPAINTER,
        "blur",
        {"image_path": image_path, "mask_path": mask_path, "radius": radius},
    )
    return _unwrap_tool_result(res)

async def _log_output(ctx: PipelineContext, image_path: str, output_path: str) -> Dict[str, Any]:
    res = await call_tool(ctx, AUDITOR, "log_output", {"image_path": image_path, "output_path": output_path})
    return _unwrap_tool_result(res)


async def _audit_output(ctx: PipelineContext, image_path: str, output_path: str) -> Dict[str, Any]:
    """Audit the output image for residual PII.
    
    Before auditing, creates a temporary version with all tracked inpainted areas blacked out
    (persons, license plates, traffic signs, PII objects) so the VLM doesn't re-detect them.
    """
    from PIL import Image
    import numpy as np
    
    # Collect all masks to black out
    all_masks_to_hide = []
    if ctx.person_masks:
        all_masks_to_hide.extend([m for m in ctx.person_masks if Path(m).exists()])
    if ctx.license_plate_masks:
        all_masks_to_hide.extend([m for m in ctx.license_plate_masks if Path(m).exists()])
    if ctx.traffic_sign_masks:
        all_masks_to_hide.extend([m for m in ctx.traffic_sign_masks if Path(m).exists()])
    if ctx.inpainted_masks:
        all_masks_to_hide.extend([m for m in ctx.inpainted_masks if Path(m).exists()])
    
    audit_image_path = output_path  # Default: audit the original
    
    if all_masks_to_hide:
        # Create a version with all tracked areas blacked out
        print(f"[audit] Creating masked version for audit with {len(all_masks_to_hide)} area(s) hidden", file=sys.stderr, flush=True)
        
        try:
            # Load the output image
            img = Image.open(output_path).convert('RGB')
            img_arr = np.array(img)
            
            # Create combined mask of all areas to hide
            combined_mask = np.zeros(img_arr.shape[:2], dtype=bool)
            
            # Dilation parameters (configurable via env)
            dilation_iterations = int(os.getenv("AUDIT_MASK_DILATE_ITERATIONS", "5"))
            dilation_kernel_size = int(os.getenv("AUDIT_MASK_DILATE_KERNEL", "5"))

            if dilation_iterations > 0:
                print(f"[audit] Dilating masks: {dilation_iterations} iterations with {dilation_kernel_size}x{dilation_kernel_size} kernel", file=sys.stderr, flush=True)

            for mask_path in all_masks_to_hide:
                try:
                    mask = Image.open(mask_path).convert('L')
                    if mask.size != img.size:
                        print(
                            f"[audit] Warning: Mask {mask_path} size {mask.size} != image size {img.size}, resizing",
                            file=sys.stderr,
                            flush=True,
                        )
                        mask = mask.resize(img.size, Image.NEAREST)
                    mask_arr = np.array(mask) > 127
                    if mask_arr.shape != combined_mask.shape:
                        print(
                            f"[audit] Warning: Mask {mask_path} shape mismatch after resize; skipping",
                            file=sys.stderr,
                            flush=True,
                        )
                        continue

                    # Dilate mask to ensure full coverage of text edges and borders
                    if dilation_iterations > 0:
                        try:
                            import cv2
                            kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilation_kernel_size, dilation_kernel_size))
                            mask_arr_uint8 = (mask_arr.astype(np.uint8) * 255)
                            dilated_mask = cv2.dilate(mask_arr_uint8, kernel, iterations=dilation_iterations)
                            mask_arr = dilated_mask > 127
                        except Exception as e:
                            # Fallback to scipy if cv2 not available
                            try:
                                from scipy import ndimage
                                mask_arr = ndimage.binary_dilation(mask_arr, iterations=dilation_iterations)
                            except Exception:
                                print(f"[audit] Warning: Failed to dilate mask {mask_path}, using original: {e}", file=sys.stderr, flush=True)

                    combined_mask |= mask_arr
                except Exception as e:
                    print(f"[audit] Warning: Failed to load mask {mask_path}: {e}", file=sys.stderr, flush=True)
            
            # Black out all masked areas
            img_arr[combined_mask] = 0

            # Save audit masked image to companion folder (temporary, will be cleaned up later)
            # This is used only during audit processing and should not be in final output
            companion_folder_path = Path(output_path).parent / Path(output_path).stem
            companion_folder_path.mkdir(parents=True, exist_ok=True)
            audit_image_path = str(companion_folder_path / f"{Path(output_path).stem}_audit_masked.png")

            masked_img = Image.fromarray(img_arr)
            masked_img.save(audit_image_path)

            print(f"[audit] Saved temporary audit image to: {audit_image_path}", file=sys.stderr, flush=True)
            print(f"[audit] Masked pixels: {combined_mask.sum()}", file=sys.stderr, flush=True)
            
        except Exception as e:
            print(f"[audit] Warning: Failed to create masked audit image: {e}", file=sys.stderr, flush=True)
            audit_image_path = output_path  # Fallback to original
    else:
        print(f"[audit] No masks to hide, auditing original image", file=sys.stderr, flush=True)
    
    # Call auditor with the (possibly masked) image
    res = await call_tool(ctx, AUDITOR, "audit_output", {"image_path": image_path, "output_path": audit_image_path})
    result = _unwrap_tool_result(res)

    if not isinstance(result, dict):
        result = {}

    attempts = getattr(ctx, "audit_attempts", 0) + 1
    ctx.audit_attempts = attempts
    max_attempts = getattr(ctx, "max_audit_attempts", 3) or 3

    residual_candidates: List[Dict[str, Any]] = []
    for key in ("residual", "residuals", "instances", "residual_instances"):
        value = result.get(key) if isinstance(result, dict) else None
        if isinstance(value, list) and value:
            residual_candidates = value
            break

    residual_list = list(residual_candidates) if residual_candidates else []

    audit_passed = bool(result.get("ok")) and not residual_candidates
    max_reached = attempts >= max_attempts
    ctx.audit_max_reached = max_reached and not audit_passed

    if residual_list and not (max_reached and not audit_passed):
        ctx.pending_residual_instances = residual_list
    else:
        ctx.pending_residual_instances = []

    result["audit_attempt"] = attempts
    result["max_attempts"] = max_attempts
    result["max_attempts_reached"] = max_reached
    result["audit_passed"] = audit_passed

    try:
        ctx.audit_history.append(copy.deepcopy(result))
    except Exception:
        # Fallback if copy fails; store shallow reference
        ctx.audit_history.append(dict(result))

    return result


def _setup_gsam2_env() -> None:
    """Set up Grounded-SAM-2 environment variables."""
    os.environ.setdefault("DETECTOR_BACKEND", "grounded-sam-2")
    os.environ.setdefault("DETECTOR_DESCRIBE_PERSONS", "true")
    os.environ.setdefault("GROUNDED_SAM2_CONDA_ENV", "gsam2")
    
    # Set default path if not configured
    if not os.getenv("GROUNDED_SAM2_DIR"):
        default_path = Path(__file__).parent.parent.parent / "models" / "Grounded-SAM-2"
        if default_path.exists():
            os.environ["GROUNDED_SAM2_DIR"] = str(default_path)

def _llm_config() -> Dict[str, Any]:
    """Get LLM configuration for Autogen agents (Gemini, OpenAI, or Ollama).
    
    Priority order: Gemini > OpenAI > Ollama (default)
    When Gemini or OpenAI is used, Ollama models are not loaded.
    """
    use_gemini = os.getenv("USE_GEMINI", "false").lower() in ("true", "1", "yes")
    use_openai = os.getenv("USE_OPENAI", "false").lower() in ("true", "1", "yes")
    
    if use_gemini:
        # Google Gemini - paid, cloud (via OpenAI-compatible endpoint)
        return {
            "config_list": [{
                "model": os.getenv("AUTOGEN_MODEL", "gemini-2.0-flash-exp"),
                "api_key": os.getenv("GEMINI_API_KEY", ""),
                "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
            }],
            "temperature": 0,
            "timeout": int(os.getenv("AUTOGEN_TIMEOUT", "300")),
        }
    elif use_openai:
        # OpenAI - paid, cloud
        return {
            "config_list": [{
                "model": os.getenv("AUTOGEN_MODEL", "gpt-4o-mini"),
                "api_key": os.getenv("OPENAI_API_KEY", ""),
            }],
            "temperature": 0,
            "timeout": int(os.getenv("AUTOGEN_TIMEOUT", "120")),
        }
    
    # Ollama (default) - local and free
    # Only used if neither Gemini nor OpenAI is enabled
    return {
        "config_list": [{
            "model": os.getenv("AUTOGEN_MODEL", "mistral-small:24b"),
            "base_url": os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"),
            "api_key": "ollama",
        }],
        "temperature": 0,
        "timeout": int(os.getenv("AUTOGEN_TIMEOUT", "300")),
    }


def main() -> int:
    """Main entry point for anonymization pipeline."""
    import warnings
    warnings.filterwarnings("ignore", message="Unable to find acceptable character detection dependency")

    # Load .env file if available (python-dotenv or manual fallback)
    def _load_env_file() -> None:
        try:
            from dotenv import load_dotenv  # type: ignore
            load_dotenv(override=False)
        except ImportError:
            # Manual fallback: load .env from repo root
            env_path = Path(__file__).resolve().parents[3] / ".env"
            if env_path.exists():
                with env_path.open("r", encoding="utf-8") as f:
                    for line in f:
                        line = line.strip()
                        if line and not line.startswith("#") and "=" in line:
                            key, val = line.split("=", 1)
                            key, val = key.strip(), val.strip().strip('"\'')
                            if key and key not in os.environ:
                                os.environ[key] = os.path.expandvars(val)

    _load_env_file()
    
    # Set up Grounded-SAM-2 environment (always used)
    _setup_gsam2_env()
    
    # Start overall main timer as early as possible
    t_main_start = perf_counter()
    
    # No global session summary; handled via PipelineContext
    parser = argparse.ArgumentParser(description="Autogen anonymization orchestrator")
    parser.add_argument("image", help="Path to input image")
    parser.add_argument("output", nargs="?", help="(Deprecated - ignored) Output path is auto-generated as artifacts/{relative_path}/{stem}{suffix}")
    # Removed logging/transcript flags
    parser.add_argument("--cleanup-tmp", action="store_true", help="Delete temporary masks and intermediate blurred outputs after completion")
    args = parser.parse_args()

    image_path = args.image
    output_path = args.output
    ctx = PipelineContext(
        image_path=image_path,
        output_path=output_path,
    )

    # Initialize canonical output with input-specific name
    global CANONICAL_OUTPUT
    src = Path(image_path)
    if not src.exists() or not src.is_file():
        print(f"[error] Input image not found: {image_path}", file=sys.stderr, flush=True)
        _cleanup_terminal_logging()
        return 2
    
    # Set canonical output to artifacts/{relative_parent}/{input_stem}{input_suffix}
    # This preserves the directory structure and original extension: test_data/img.png -> artifacts/test_data/img.png
    input_stem = src.stem
    input_suffix = src.suffix if src.suffix else ".jpeg"
    # Get parent directory relative to current working directory (or use parent name if absolute path)
    try:
        # Resolve to absolute path first, then get relative to cwd
        src_absolute = src.resolve()
        relative_parent = src_absolute.parent.relative_to(Path.cwd())
    except ValueError:
        # If path is outside cwd, just use the parent directory name
        relative_parent = src.parent.name if src.parent.name else "."
    
    output_dir = Path("artifacts") / relative_parent
    CANONICAL_OUTPUT = output_dir / f"{input_stem}{input_suffix}"
    CANONICAL_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
    
    # Create companion folder for all related files (logs, masks, JSON)
    # Structure: image.png + image/ (containing log, masks, JSON)
    companion_folder = output_dir / input_stem
    companion_folder.mkdir(parents=True, exist_ok=True)
    
    # Set up terminal logging to file in companion folder
    log_file_path = companion_folder / f"{input_stem}.log"
    _setup_terminal_logging(log_file_path)
    
    if src.resolve() != CANONICAL_OUTPUT.resolve():
        try:
            shutil.copyfile(src, CANONICAL_OUTPUT)
        except Exception as e:
            print(f"[error] Failed to copy input to canonical output: {e}", file=sys.stderr, flush=True)
            _cleanup_terminal_logging()
            return 2
    # Ensure subsequent tools operate on the canonical path
    ctx.last_output = str(CANONICAL_OUTPUT)
    # Update context with actual output path (not user-provided path)
    ctx.output_path = str(CANONICAL_OUTPUT)

    # Define agents following the requested mapping
    llm_cfg = _llm_config()
    # Build agents via shared builders
    from mcp.clients.agents import (
        build_user_proxy,
        build_orchestrator,
    build_auditor,
    build_generative,
    )

    user = build_user_proxy()
    orchestrator = build_orchestrator(llm_cfg)
    generative_agent = build_generative(llm_cfg)
    auditor_agent = build_auditor(llm_cfg)
    
    # Note: Detection and Technician agents removed - their functions are called
    # directly in Phase 1 (deterministic pre-processing) without LLM orchestration

    # Removed direct pre-step person segmentation/blur; delegated to Technician if needed.
    start_image = image_path

    # Register MCP-backed tools to the respective agents
    # Analyzer-owned tool
    def classify_pii_tool(image: str) -> dict:
        print(f"[TOOL_CALL] classify_pii_tool invoked with image={image}", file=sys.stderr, flush=True)
        # Always analyze the canonical working image (use absolute path for MCP server)
        eff_image = str(CANONICAL_OUTPUT.resolve())
        # Anti-repeat: allow at most 2 analyzes per eff_image
        tries = 0
        try:
            for s in ctx.steps or []:
                if s.get("op") == "classify_pii" and s.get("image") == eff_image:
                    tries += 1
        except Exception:
            pass
        if tries >= 2:
            print(f"[TOOL_RESULT] classify_pii_tool: repeated too often (tries={tries})", file=sys.stderr, flush=True)
            return {"ok": False, "error": "classify_pii repeated too often on same image", "image": eff_image}
        print(f"[TOOL_EXEC] classify_pii_tool: calling _classify for {eff_image}", file=sys.stderr, flush=True)
        res = asyncio.run(_classify(ctx, eff_image))
        print(f"[TOOL_EXEC] classify_pii_tool: _classify returned, processing {len(res.get('instances', [])) if isinstance(res, dict) else 0} instances", file=sys.stderr, flush=True)
        try:
            if isinstance(res, dict):
                normalized = _normalize_instances(ctx, res.get("instances"), source="classify_pii")
                
                # Filter out instances that overlap ≥50% with already masked areas
                # (persons, license plates, traffic signs, previously inpainted areas)
                filtered_instances = []
                skipped_instances = []
                
                # Collect all existing masks for overlap checking
                all_existing_masks = []
                if ctx.person_masks:
                    all_existing_masks.extend([m for m in ctx.person_masks if Path(m).exists()])
                if ctx.license_plate_masks:
                    all_existing_masks.extend([m for m in ctx.license_plate_masks if Path(m).exists()])
                if ctx.traffic_sign_masks:
                    all_existing_masks.extend([m for m in ctx.traffic_sign_masks if Path(m).exists()])
                if ctx.inpainted_masks:
                    all_existing_masks.extend([m for m in ctx.inpainted_masks if Path(m).exists()])
                
                print(f"[classify_pii] Checking {len(normalized)} instances against {len(all_existing_masks)} existing masks", file=sys.stderr, flush=True)
                
                for instance in normalized:
                    description = instance.get('description', '')
                    det_prompt = instance.get('det_prompt', description)
                    bbox = instance.get('bbox')
                    
                    # Skip if no bbox or no existing masks to check against
                    if not bbox or not all_existing_masks:
                        filtered_instances.append(instance)
                        continue
                    
                    # Generate mask for this instance to check overlap
                    # Use scout-and-zoom approach: crop, detect, map back
                    try:
                        from PIL import Image
                        import hashlib
                        
                        img = Image.open(str(CANONICAL_OUTPUT))
                        img_width, img_height = img.size
                        
                        # Parse and clamp bbox
                        x, y, w, h = bbox
                        # Handle both formats: [x, y, w, h] and [x_min, y_min, x_max, y_max]
                        if w > img_width or h > img_height:
                            # Likely [x_min, y_min, x_max, y_max] format
                            x_min, y_min, x_max, y_max = x, y, w, h
                            w = x_max - x_min
                            h = y_max - y_min
                            x, y = x_min, y_min
                        
                        x = max(0, min(x, img_width - 1))
                        y = max(0, min(y, img_height - 1))
                        w = min(w, img_width - x)
                        h = min(h, img_height - y)
                        
                        if w <= 0 or h <= 0:
                            print(f"[classify_pii] Invalid bbox for '{description}', including instance", file=sys.stderr, flush=True)
                            filtered_instances.append(instance)
                            continue
                        
                        # Create crop for detection
                        crop_dir = Path("artifacts") / "tmp" / "crops_classify"
                        crop_dir.mkdir(parents=True, exist_ok=True)
                        
                        bbox_str = f"{x}_{y}_{w}_{h}"
                        crop_hash = hashlib.md5(bbox_str.encode()).hexdigest()[:8]
                        crop_path = str(crop_dir / f"classify_crop_{crop_hash}.png")
                        
                        crop_img = img.crop((x, y, x + w, y + h))
                        crop_img.save(crop_path)
                        
                        # Run detection on crop
                        crop_detect_result = asyncio.run(_detect(ctx, crop_path, det_prompt))
                        crop_mask_path = crop_detect_result.get("mask_path")
                        
                        if not crop_mask_path or not Path(crop_mask_path).exists() or _mask_is_empty(crop_mask_path):
                            print(f"[classify_pii] No valid mask for '{description}', including instance", file=sys.stderr, flush=True)
                            filtered_instances.append(instance)
                            continue
                        
                        # Map crop mask to full image coordinates
                        mask_dir = companion_folder / "masks"
                        mask_dir.mkdir(parents=True, exist_ok=True)
                        full_mask_path = str(mask_dir / f"classify_check_{crop_hash}_{Path(crop_mask_path).name}")
                        
                        if not _map_mask_to_full_image(crop_mask_path, [x, y, w, h], (img_width, img_height), full_mask_path):
                            print(f"[classify_pii] Failed to map mask for '{description}', including instance", file=sys.stderr, flush=True)
                            filtered_instances.append(instance)
                            continue
                        
                        # Check overlap with existing masks
                        if _mask_overlaps_inpainted_areas(full_mask_path, all_existing_masks, threshold=0.5):
                            print(f"[classify_pii] ⊗ SKIP: '{description}' overlaps ≥50% with existing masked areas", file=sys.stderr, flush=True)
                            skipped_instances.append({
                                'instance': instance,
                                'reason': 'overlaps_existing_masks',
                                'description': description
                            })
                        else:
                            print(f"[classify_pii] ✓ INCLUDE: '{description}' - no significant overlap", file=sys.stderr, flush=True)
                            filtered_instances.append(instance)
                        
                        # Clean up temporary mask
                        try:
                            Path(full_mask_path).unlink()
                        except Exception:
                            pass
                            
                    except Exception as e:
                        print(f"[classify_pii] Warning: Failed to check overlap for '{description}': {e}", file=sys.stderr, flush=True)
                        # Include instance on error to be safe
                        filtered_instances.append(instance)
                
                print(f"[classify_pii] Filtered: {len(filtered_instances)} included, {len(skipped_instances)} skipped due to overlap", file=sys.stderr, flush=True)
                
                ctx.instances = filtered_instances
                res["instances"] = filtered_instances
                if skipped_instances:
                    res["skipped_instances"] = skipped_instances
                    
            # Record classify step for anti-repeat checks
            try:
                ctx.steps.append({
                    "op": "classify_pii",
                    "image": eff_image,
                    "instances_count": len(ctx.instances or []),
                    "instances": copy.deepcopy(ctx.instances or []),
                })
            except Exception:
                pass
        except Exception as e:
            print(f"[TOOL_ERROR] classify_pii_tool: exception during processing: {e}", file=sys.stderr, flush=True)
            pass
        
        print(f"[TOOL_RESULT] classify_pii_tool: returning {len(res.get('instances', [])) if isinstance(res, dict) else 0} instances", file=sys.stderr, flush=True)
        return res

    # Segmentation/detection helpers
    def segment_persons_tool(image: str) -> dict:
        # Always segment on the canonical working image
        eff_image = str(CANONICAL_OUTPUT)
        res = asyncio.run(_segment_persons(ctx, eff_image))
        
        # Track all person masks for later exclusion from PII object detection
        # AND store person instances for deterministic batch processing
        try:
            # Check for instances - 'ok' field is optional (not all detectors return it)
            if isinstance(res, dict) and 'instances' in res:
                # Store all detected person instances in context for later use
                ctx.person_instances = res.get('instances', [])
                print(f"[Detection] Stored {len(ctx.person_instances)} person instances in context", file=sys.stderr, flush=True)
                
                # Track person masks for PII exclusion
                for instance in res.get('instances', []):
                    mask_path = instance.get('mask_path')
                    if mask_path and Path(mask_path).exists():
                        if mask_path not in ctx.person_masks:
                            ctx.person_masks.append(mask_path)
                            print(f"[Detection] Tracked person mask: {mask_path}", file=sys.stderr, flush=True)
                            
                            # IMMEDIATELY add this mask to the combined PII mask
                            try:
                                _add_mask_to_combined_pii_mask(mask_path, companion_folder, ctx.image_path)
                            except Exception as e:
                                print(f"[Detection] Warning: Failed to add person mask to combined PII mask: {e}", file=sys.stderr, flush=True)
        except Exception as e:
            print(f"[Detection] Failed to track person masks: {e}", file=sys.stderr, flush=True)
        
        # Record steps: treat each person mask as a blur step only when actually blurred later; here just return detection.
        return res

    def detect_license_plates_tool(image: str) -> dict:
        # Similar to segment_persons: always operate on canonical working image
        eff_image = str(CANONICAL_OUTPUT)
        res = asyncio.run(_detect_license_plates(ctx, eff_image))
        
        # Store all detected license plate instances in context for deterministic processing
        try:
            # Check for instances - 'ok' field is optional (not all detectors return it)
            if isinstance(res, dict) and 'instances' in res:
                ctx.license_plate_instances = res.get('instances', [])
                print(f"[Detection] Stored {len(ctx.license_plate_instances)} license plate instances in context", file=sys.stderr, flush=True)
                
                # Track license plate masks for PII exclusion (even though they get blurred)
                for instance in res.get('instances', []):
                    mask_path = instance.get('mask_path')
                    if mask_path and Path(mask_path).exists():
                        if mask_path not in ctx.license_plate_masks:
                            ctx.license_plate_masks.append(mask_path)
                            print(f"[Detection] Tracked license plate mask for exclusion: {mask_path}", file=sys.stderr, flush=True)
                            
                            # IMMEDIATELY add this mask to the combined PII mask
                            try:
                                _add_mask_to_combined_pii_mask(mask_path, companion_folder, ctx.image_path)
                            except Exception as e:
                                print(f"[Detection] Warning: Failed to add license plate mask to combined PII mask: {e}", file=sys.stderr, flush=True)
        except Exception as e:
            print(f"[Detection] Failed to store license plate instances: {e}", file=sys.stderr, flush=True)
        
        return res

    def detect_traffic_signs_tool(image: str) -> dict:
        # Detect traffic signs to mask them out for exclusion from PII detection
        eff_image = str(CANONICAL_OUTPUT)
        res = asyncio.run(_detect_traffic_signs(ctx, eff_image))
        
        # Store all detected traffic sign instances in context for exclusion
        try:
            # Check for instances - 'ok' field is optional (not all detectors return it)
            if isinstance(res, dict) and 'instances' in res:
                ctx.traffic_sign_instances = res.get('instances', [])
                print(f"[Detection] Stored {len(ctx.traffic_sign_instances)} traffic sign instances in context", file=sys.stderr, flush=True)
                
                # Track traffic sign masks for PII exclusion
                for instance in res.get('instances', []):
                    mask_path = instance.get('mask_path')
                    if mask_path and Path(mask_path).exists():
                        if mask_path not in ctx.traffic_sign_masks:
                            ctx.traffic_sign_masks.append(mask_path)
                            print(f"[Detection] Tracked traffic sign mask for exclusion: {mask_path}", file=sys.stderr, flush=True)
        except Exception as e:
            print(f"[Detection] Failed to store traffic sign instances: {e}", file=sys.stderr, flush=True)
        
        return res

    def apply_blur_tool(image: str, mask_path: str, radius: float | None = None) -> dict:
        """Apply Gaussian blur to masked regions in the image (for license plates).
        
        Args:
            image: Path to input image (canonical output)
            mask_path: Path to mask image (white=blur, black=keep)
            radius: Gaussian blur radius in pixels (default from env or 8)
            
        Returns: dict with 'output_path' or 'error'
        """
        # Call the blur tool from inpainter server
        res = asyncio.run(call_tool(ctx, INPAINTER, "blur", {
            "image_path": str(CANONICAL_OUTPUT),
            "mask_path": mask_path,
            "radius": radius
        }))
        
        # Unwrap result
        result = _unwrap_tool_result(res)
        
        # Update canonical output if successful
        if result.get("output_path") and Path(result["output_path"]).exists():
            # Blur overwrites the canonical output in place, so just update context
            ctx.last_output = result["output_path"]
            print(f"[Blur] Applied blur to {mask_path}, output: {result['output_path']}", file=sys.stderr, flush=True)
        
        return result

    # Detection Agent tools (Spotter)
    def detect_persons_tool(image: str) -> dict:
        res = segment_persons_tool(image)
        return res


    def anonymize_and_inpaint_persons_tool(instances: list = None) -> dict:
        # ALWAYS use ALL persons from ctx.person_instances (ignore instances parameter)
        # This ensures all detected persons are processed, not just what the LLM chooses to send

        all_instances = ctx.person_instances if ctx.person_instances else []

        print(f"instances: " + str(all_instances), file=sys.stderr, flush=True)
        
        if not all_instances:
            return {"status": "complete", "message": "No persons detected", "processed": 0}
        
        # Sort by detection score ascending (low scores first - process difficult cases first)
        instances_to_process = sorted(all_instances, key=lambda x: x.get('meta', {}).get('score', 0))
        
        print(f"[Generative] Processing {len(instances_to_process)} person(s) in BATCH MODE (keeps pipeline loaded)", file=sys.stderr, flush=True)
        
        # Prepare batch instances with mask_path and description
        batch_instances = []
        for idx, instance in enumerate(instances_to_process, 1):
            mask_path = instance['mask_path']
            description = instance.get('description', 'person')
            
            print(f"[Generative] Person {idx}/{len(instances_to_process)}: {mask_path}", file=sys.stderr, flush=True)
            print(f"[Generative]   Description: {description}", file=sys.stderr, flush=True)
            
            batch_instances.append({
                'mask_path': mask_path,
                'description': description
            })
        
        # Process ALL persons in one batch (pipeline stays loaded)
        print(f"\n[Generative] === Starting batch inpainting ({len(batch_instances)} persons) ===", file=sys.stderr, flush=True)
        batch_result = asyncio.run(_batch_inpaint(
            ctx,
            str(CANONICAL_OUTPUT),
            batch_instances
        ))
        
        # Update canonical output and track progress
        processed_count = batch_result.get('processed_count', 0)
        if batch_result.get('ok', True) and batch_result.get('final_output'):
            final_output = batch_result['final_output']
            if Path(final_output).exists():
                # Copy to canonical
                CANONICAL_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
                shutil.copyfile(final_output, CANONICAL_OUTPUT)
                ctx.last_output = str(CANONICAL_OUTPUT)
                
                # Track all inpainted person masks (both in inpainted_masks AND person_masks)
                for instance in instances_to_process:
                    mask_path = instance['mask_path']
                    if mask_path and Path(mask_path).exists():
                        # Add to inpainted_masks for general overlap detection
                        if mask_path not in ctx.inpainted_masks:
                            ctx.inpainted_masks.append(mask_path)
                        # Ensure it's also in person_masks (should already be there from detection)
                        if mask_path not in ctx.person_masks:
                            ctx.person_masks.append(mask_path)
                
                # Record batch step
                ctx.steps.append({
                    "op": "batch_inpaint",
                    "instances": [{"mask_path": inst['mask_path'], "description": inst.get('description', 'person')} for inst in instances_to_process],
                    "output_path": str(CANONICAL_OUTPUT),
                    "controlnets": ["openpose"],  # Persons always use pose ControlNet
                })
                
                print(f"[Generative] Batch inpainting completed successfully", file=sys.stderr, flush=True)
            else:
                print(f"[Generative] Batch inpaint failed: output not found", file=sys.stderr, flush=True)
        else:
            print(f"[Generative] Batch inpaint failed: {batch_result.get('error')}", file=sys.stderr, flush=True)
        
        print(f"\n[Generative] === Completed: {processed_count}/{len(instances_to_process)} persons inpainted ===\n", file=sys.stderr, flush=True)
        
        return {
            "status": "complete",
            "message": f"Processed {processed_count} persons sequentially",
            "processed": processed_count,
            "total": len(instances_to_process)
        }

    def anonymize_and_inpaint_tool(instances: list) -> dict:
        print(f"[TOOL_CALL] anonymize_and_inpaint_tool invoked with {len(instances)} instances", file=sys.stderr, flush=True)
        # instances is list of dict from classify_pii output, each with 'description' and optionally 'bbox'
        results = []
        skipped_count = 0
        object_backend = "sdxl" if _truthy_env("INPAINT_OBJECT_USE_SDXL", True) else "sd15"

        # Parse instances: handle case where LLM passes a list with a single string containing multiple dicts
        parsed_instances = []
        for item in instances:
            if isinstance(item, str):
                # Try to parse as JSON first, then as Python literal
                import json
                import ast
                import re
                
                # COMMON LLM ERROR: Single string containing comma-separated dicts like:
                # "{'det_prompt': '...', 'bbox': [...]}, {'det_prompt': '...', 'bbox': [...]}"
                # This needs to be wrapped in brackets and parsed as a Python list
                
                try:
                    # Attempt 1: Try as-is if it's already valid JSON
                    parsed = json.loads(item)
                    if isinstance(parsed, list):
                        parsed_instances.extend(parsed)
                    else:
                                               parsed_instances.append(parsed)
                    print(f"[PARSE] Successfully parsed as JSON", file=sys.stderr, flush=True)
                except (json.JSONDecodeError, ValueError):
                    try:
                        # Attempt 2: Try as Python literal (might be a single dict or list)
                        parsed = ast.literal_eval(item)
                        if isinstance(parsed, (list, tuple)):
                            parsed_instances.extend(parsed)
                            print(f"[PARSE] Successfully parsed as Python {type(parsed).__name__} literal ({len(parsed)} items)", file=sys.stderr, flush=True)
                        elif isinstance(parsed, dict):
                            parsed_instances.append(parsed)
                            print(f"[PARSE] Successfully parsed as Python dict literal", file=sys.stderr, flush=True)
                        else:
                            print(f"[PARSE] Warning: Parsed but unexpected type: {type(parsed)}", file=sys.stderr, flush=True)
                    except (ValueError, SyntaxError) as e:
                        # Attempt 3: Try wrapping in brackets (comma-separated dicts without array)
                        try:
                            wrapped = f"[{item}]"
                            print(f"[PARSE] Attempting to parse wrapped string: [{item[:100]}...]", file=sys.stderr, flush=True)
                            parsed = ast.literal_eval(wrapped)
                            if isinstance(parsed, list):
                                parsed_instances.extend(parsed)
                                print(f"[PARSE] ✓ Successfully parsed wrapped string as list ({len(parsed)} items)", file=sys.stderr, flush=True)
                            else:
                                print(f"[PARSE] Warning: Wrapped parse didn't produce list: {type(parsed)}", file=sys.stderr, flush=True)
                        except (ValueError, SyntaxError) as e2:
                            # Attempt 4: Try replacing single quotes with double quotes for JSON
                            try:
                                json_friendly = item.replace("'", '"')
                                wrapped_json = f"[{json_friendly}]"
                                parsed = json.loads(wrapped_json)
                                if isinstance(parsed, list):
                                    parsed_instances.extend(parsed)
                                    print(f"[PARSE] ✓ Successfully parsed as JSON after quote conversion ({len(parsed)} items)", file=sys.stderr, flush=True)
                                else:
                                    print(f"[PARSE] Warning: JSON parse didn't produce list: {type(parsed)}", file=sys.stderr, flush=True)
                            except (json.JSONDecodeError, ValueError) as e3:
                                print(f"[PARSE] ✗ FAILED all parsing attempts:", file=sys.stderr, flush=True)
                                print(f"[PARSE]   - JSON parse error: {e}", file=sys.stderr, flush=True)
                                print(f"[PARSE]   - Wrapped literal_eval error: {e2}", file=sys.stderr, flush=True)
                                print(f"[PARSE]   - JSON with quotes error: {e3}", file=sys.stderr, flush=True)
                                print(f"[PARSE]   - Original string (first 300 chars): {item[:300]}", file=sys.stderr, flush=True)
                                continue
            elif isinstance(item, dict):
                parsed_instances.append(item)
                print(f"[PARSE] Item is already a dict", file=sys.stderr, flush=True)
            elif isinstance(item, (list, tuple)):
                # Handle nested lists/tuples
                for subitem in item:
                    if isinstance(subitem, dict):
                        parsed_instances.append(subitem)
                print(f"[PARSE] Extracted {len(item)} dicts from nested list", file=sys.stderr, flush=True)
            else:
                print(f"[PARSE] Warning: Unknown instance type {type(item)}: {item}", file=sys.stderr, flush=True)

        instances = parsed_instances

        print(f"\n{'='*80}", file=sys.stderr, flush=True)
        print(f"[DEBUG] Starting anonymize_and_inpaint_tool with {len(instances)} instances", file=sys.stderr, flush=True)
        print(f"{'='*80}\n", file=sys.stderr, flush=True)

        for idx, instance in enumerate(instances, 1):
            # Initialize instance_id early for error handling
            instance_id = None
            # Wrap entire instance processing in try-except to prevent TaskGroup errors from crashing the tool
            try:
                # Ensure instance is a dict at this point
                if not isinstance(instance, dict):
                    print(f"[DEBUG] Error: Instance {idx} is not a dict: {type(instance)}", file=sys.stderr, flush=True)
                    continue

                instance_id = instance.get("instance_id")
                # Handle both formats: 'description' (from classify_pii) or 'class_name' (from agent)
                description = instance.get('description') or instance.get('class_name', 'unknown object')
                # Use description for both detection AND inpainting (detailed, effective for both)
                detection_prompt = description
                # Handle both bbox formats: 'bbox' (from classify_pii) or 'box_2d' (from agent)
                bbox = instance.get('bbox') or instance.get('box_2d')

                ctx.active_instance_id = instance_id
                _update_instance_record(
                    ctx,
                    {
                        "instance_id": instance_id,
                        "description": description,
                        "det_prompt": instance.get("det_prompt") or detection_prompt,
                        "bbox": bbox,
                        "status": "pending",
                    },
                    source="anonymize_and_inpaint_request",
                )
                
                print(f"\n[DEBUG] Processing instance {idx}/{len(instances)}", file=sys.stderr, flush=True)
                print(f"[DEBUG]   Description: {description}", file=sys.stderr, flush=True)
                print(f"[DEBUG]   Bbox: {bbox}", file=sys.stderr, flush=True)

                # IoU-based duplicate check: skip if this bbox significantly overlaps with already-processed instances
                if bbox and isinstance(bbox, list) and len(bbox) == 4:
                    iou_threshold = float(os.getenv("PII_IOU_THRESHOLD", "0.3"))
                    if _check_iou_overlap_with_processed(ctx, bbox, iou_threshold):
                        print(f"[DEBUG]   ⚠️  SKIP: High IoU overlap with already-processed instance", file=sys.stderr, flush=True)
                        _update_instance_record(
                            ctx,
                            {
                                "instance_id": instance_id,
                                "description": description,
                                "det_prompt": detection_prompt,
                                "bbox": bbox,
                                "status": "skipped",
                                "reason": "iou_overlap_with_processed",
                            },
                            source="anonymize_and_inpaint",
                        )
                        skipped_count += 1
                        ctx.pending_detect = None
                        ctx.active_instance_id = None
                        results.append({
                            "status": "skipped",
                            "reason": "iou_overlap_with_processed",
                            "description": description
                        })
                        continue

                # Check if we have bbox information for scout-and-zoom style segmentation
                if bbox and isinstance(bbox, list) and len(bbox) == 4:
                    # Use bbox for scout-and-zoom style segmentation: crop region and detect within it
                    try:
                        from PIL import Image
                        import hashlib
                        
                        # Get image dimensions
                        img = Image.open(str(CANONICAL_OUTPUT))
                        img_w, img_h = img.size
                        
                        # Validate and clamp bbox coordinates
                        x, y, w, h = bbox
                        print(f"[DEBUG]   Raw bbox: x={x}, y={y}, w={w}, h={h}", file=sys.stderr, flush=True)
                        
                        if w <= 0 or h <= 0:
                            print(f"[DEBUG]   ❌ Invalid bbox dimensions {bbox}, falling back to detection", file=sys.stderr, flush=True)
                            bbox = None
                        else:
                            # Clamp coordinates to image bounds
                            x = max(0, min(x, img_w - 1))
                            y = max(0, min(y, img_h - 1))
                            w = min(w, img_w - x)
                            h = min(h, img_h - y)
                            
                            print(f"[DEBUG]   Clamped bbox: x={x}, y={y}, w={w}, h={h} (image: {img_w}x{img_h})", file=sys.stderr, flush=True)
                            
                            if w <= 0 or h <= 0:
                                print(f"[DEBUG]   ❌ Bbox too small after clamping {bbox} -> [{x},{y},{w},{h}], falling back to detection", file=sys.stderr, flush=True)
                                bbox = None
                            else:
                                clamped_bbox = [x, y, w, h]
                                print(f"[DEBUG]   ✓ Using clamped bbox {clamped_bbox} for scout-and-zoom segmentation", file=sys.stderr, flush=True)
                                
                                # Create crop from bbox region
                                crop_dir = Path("artifacts") / "tmp" / "crops"
                                crop_dir.mkdir(parents=True, exist_ok=True)
                                
                                bbox_str = f"{x}_{y}_{w}_{h}"
                                crop_hash = hashlib.md5(bbox_str.encode()).hexdigest()[:8]
                                crop_path = str(crop_dir / f"crop_{crop_hash}.png")
                                
                                # Crop the region
                                crop_img = img.crop((x, y, x + w, y + h))
                                crop_img.save(crop_path)
                                print(f"[DEBUG]   📁 Saved crop: {crop_path} (size: {crop_img.size})", file=sys.stderr, flush=True)
                                print(f"[DEBUG]   🔍 Running detection on crop with prompt: '{detection_prompt}'", file=sys.stderr, flush=True)
                                
                                # Run detection on the cropped region
                                prev_backend = os.getenv("DETECTOR_BACKEND")
                                try:
                                    os.environ["DETECTOR_BACKEND"] = "grounded-sam-2"
                                    crop_detect_result = asyncio.run(_detect(ctx, crop_path, detection_prompt))
                                    print(f"[DEBUG]   Detection result: {crop_detect_result.get('ok', False)}, mask: {crop_detect_result.get('mask_path', 'None')}", file=sys.stderr, flush=True)
                                finally:
                                    if prev_backend:
                                        os.environ["DETECTOR_BACKEND"] = prev_backend
                                    else:
                                        os.environ.pop("DETECTOR_BACKEND", None)
                                
                                # Extract crop mask
                                crop_mask_path = crop_detect_result.get("mask_path")
                                print(f"[DEBUG]   Crop mask path: {crop_mask_path}", file=sys.stderr, flush=True)
                                
                                if crop_mask_path and Path(crop_mask_path).exists():
                                    print(f"[DEBUG]   ✓ Crop mask exists", file=sys.stderr, flush=True)
                                    is_empty = _mask_is_empty(crop_mask_path)
                                    print(f"[DEBUG]   Mask empty check: {is_empty}", file=sys.stderr, flush=True)
                                    
                                    if not is_empty:
                                        # Map crop mask back to full image coordinates
                                        mask_dir = companion_folder / "masks"
                                        mask_dir.mkdir(parents=True, exist_ok=True)
                                        full_mask_path = str(mask_dir / f"mapped_{crop_hash}_{Path(crop_mask_path).name}")
                                        
                                        print(f"[DEBUG]   🗺️  Mapping crop mask to full image: {full_mask_path}", file=sys.stderr, flush=True)
                                        
                                        if _map_mask_to_full_image(crop_mask_path, clamped_bbox, (img_w, img_h), full_mask_path):
                                            print(f"[DEBUG]   ✓ Successfully mapped mask from bbox region", file=sys.stderr, flush=True)
                                            
                                            # Use the mapped mask directly for inpainting (no overlap suppression)
                                            mask_path = full_mask_path
                                            detect_result = {"ok": True, "mask_path": mask_path, "method": "bbox_scout_zoom"}
                                            print(f"[DEBUG]   ✓ Scout-and-zoom succeeded, mask ready for inpainting", file=sys.stderr, flush=True)
                                        else:
                                            print(f"[DEBUG]   ❌ Failed to map mask to full image", file=sys.stderr, flush=True)
                                            bbox = None
                                    else:
                                        print(f"[DEBUG]   ❌ Crop mask is empty, falling back to detection", file=sys.stderr, flush=True)
                                        bbox = None
                                else:
                                    print(f"[DEBUG]   ❌ No valid mask found in bbox region, falling back to detection", file=sys.stderr, flush=True)
                                    bbox = None
                    except Exception as e:
                        print(f"[DEBUG]   ❌ Exception during bbox-based segmentation: {e}", file=sys.stderr, flush=True)
                        import traceback
                        traceback.print_exc()
                        bbox = None
            
                
                # SKIP if no valid bbox (do NOT fallback to full-image detection)
                if not bbox:
                    print(f"[DEBUG]   ❌ SKIP: Scout-and-zoom failed, no fallback to full-image detection", file=sys.stderr, flush=True)
                    _update_instance_record(
                        ctx,
                        {
                            "instance_id": instance_id,
                            "description": description,
                            "det_prompt": detection_prompt,
                            "bbox": bbox,
                            "status": "skipped",
                            "reason": "scout_zoom_failed_no_fallback",
                        },
                        source="anonymize_and_inpaint",
                    )
                    skipped_count += 1
                    ctx.pending_detect = None
                    ctx.active_instance_id = None
                    results.append({
                        "status": "skipped",
                        "reason": "scout_zoom_failed_no_fallback",
                        "description": description
                    })
                    continue
                
                if not detect_result.get('ok', True):
                    # Error handling: skip or log based on error type
                    skip_reason = detect_result.get('skip_reason', 'detection_failed')
                    error_msg = detect_result.get('error', 'Unknown error')
                    
                    print(f"[DEBUG]   ❌ Detection not OK: skip_reason={skip_reason}, error={error_msg}", file=sys.stderr, flush=True)
                    
                    # CRITICAL: Clear pending_detect when skipping to avoid audit loop
                    ctx.pending_detect = None
                    
                    status_for_record = "skipped" if detect_result.get('give_up') or skip_reason != 'detection_failed' else "error"
                    _update_instance_record(
                        ctx,
                        {
                            "instance_id": instance_id,
                            "description": description,
                            "det_prompt": detection_prompt,
                            "status": status_for_record,
                            "reason": skip_reason,
                            "error": error_msg,
                        },
                        source="detect",
                    )

                    if detect_result.get('give_up') or skip_reason == 'complete_overlap':
                        # Permanent failure - skip and continue with next instance
                        print(f"[Generative] Skipping PII object '{description}': {error_msg}", file=sys.stderr, flush=True)
                        skipped_count += 1
                        ctx.active_instance_id = None
                        results.append({"status": "skipped", "reason": skip_reason, "description": description, "error": error_msg, "instance_id": instance_id})
                        continue
                    else:
                        # Transient failure - log but continue
                        print(f"[Generative] Detection failed for '{description}': {error_msg}", file=sys.stderr, flush=True)
                        ctx.active_instance_id = None
                        results.append({"status": "error", "reason": "detection_failed", "description": description, "error": error_msg, "instance_id": instance_id})
                        continue
                
                # Check if mask_path is present
                if not detect_result.get('mask_path'):
                    print(f"[Generative] No mask found for PII object '{description}'", file=sys.stderr, flush=True)
                    skipped_count += 1
                    # CRITICAL: Clear pending_detect when skipping to avoid audit loop
                    ctx.pending_detect = None
                    _update_instance_record(
                        ctx,
                        {
                            "instance_id": instance_id,
                            "description": description,
                            "det_prompt": detection_prompt,
                            "status": "skipped",
                            "reason": "no_mask",
                        },
                        source="detect",
                    )
                    ctx.active_instance_id = None
                    results.append({"status": "skipped", "reason": "no_mask", "description": description, "instance_id": instance_id})
                    continue
                
                mask_path = detect_result['mask_path']
                
                # Use description for inpainting (detailed, effective for SDXL)
                inpaint_prompt = description
                
                print(f"[Generative] Inpainting PII object: '{detection_prompt}'", file=sys.stderr, flush=True)
                
                # CRITICAL: Subtract already-processed areas (persons, license plates, traffic signs) from PII mask
                # This prevents inpainting from touching areas that have already been anonymized
                original_mask_path = mask_path  # Keep original for PII mask tracking
                masks_to_subtract = []
                
                # Collect all masks that should be protected from PII inpainting
                if ctx.person_masks:
                    masks_to_subtract.extend([m for m in ctx.person_masks if Path(m).exists()])
                if ctx.license_plate_masks:
                    masks_to_subtract.extend([m for m in ctx.license_plate_masks if Path(m).exists()])
                if ctx.traffic_sign_masks:
                    masks_to_subtract.extend([m for m in ctx.traffic_sign_masks if Path(m).exists()])
                
                # Subtract protected areas from the PII mask
                if masks_to_subtract:
                    print(f"[Generative] Subtracting {len(masks_to_subtract)} protected area(s) from PII mask", file=sys.stderr, flush=True)
                    
                    # Create a new mask path for the subtracted mask (for inpainting only)
                    mask_dir = Path(mask_path).parent
                    mask_name = Path(mask_path).stem
                    subtracted_mask_path = str(mask_dir / f"{mask_name}_subtracted.png")
                    
                    # Subtract the protected masks
                    if _subtract_masks_from_mask(mask_path, masks_to_subtract, subtracted_mask_path):
                        print(f"[Generative] ✓ Successfully subtracted protected areas, using: {subtracted_mask_path}", file=sys.stderr, flush=True)
                        mask_path = subtracted_mask_path  # Use subtracted mask for inpainting
                    else:
                        print(f"[Generative] ⚠️  Mask subtraction failed or resulted in empty mask, skipping this instance", file=sys.stderr, flush=True)
                        skipped_count += 1
                        ctx.pending_detect = None
                        _update_instance_record(
                            ctx,
                            {
                                "instance_id": instance_id,
                                "description": description,
                                "det_prompt": detection_prompt,
                                "status": "skipped",
                                "reason": "empty_after_protected_area_subtraction",
                            },
                            source="inpaint",
                        )
                        ctx.active_instance_id = None
                        results.append({
                            "status": "skipped",
                            "reason": "empty_after_protected_area_subtraction",
                            "description": description,
                            "instance_id": instance_id,
                        })
                        continue
                else:
                    print(f"[Generative] No protected areas to subtract", file=sys.stderr, flush=True)
                
                eff_image = str(CANONICAL_OUTPUT)
                res = asyncio.run(_inpaint(ctx, eff_image, mask_path, inpaint_prompt, description=inpaint_prompt))
                
                _update_instance_record(
                    ctx,
                    {
                        "instance_id": instance_id,
                        "description": description,
                        "det_prompt": detection_prompt,
                        "mask_path": original_mask_path,  # Use original mask for tracking
                        "status": "detected",
                    },
                    source="detect",
                )

                try:
                    # Check for errors in result
                    if isinstance(res, dict) and res.get("error"):
                        print(f"[Generative] Inpainting failed: {res.get('error')}", file=sys.stderr, flush=True)
                        _update_instance_record(
                            ctx,
                            {
                                "instance_id": instance_id,
                                "description": description,
                                "status": "error",
                                "error": res.get("error"),
                            },
                            source="inpaint",
                        )
                        ctx.active_instance_id = None
                        results.append({"status": "error", "error": res.get("error"), "description": description, "instance_id": instance_id})
                        # Don't track failed inpainting
                        continue
                    
                    out_path = res.get("output_path") if isinstance(res, dict) else None
                    controlnets = res.get("controlnets", []) if isinstance(res, dict) else []
                    
                    # Only track mask if inpainting was successful (output exists)
                    if out_path and Path(out_path).exists():
                        # Track ORIGINAL mask (before subtraction) for PII mask creation
                        # This ensures the PII mask includes the full PII area, not the subtracted version
                        if original_mask_path and Path(original_mask_path).exists():
                            if original_mask_path not in ctx.inpainted_masks:
                                ctx.inpainted_masks.append(original_mask_path)
                                print(f"[Generative] Tracked inpainted mask (original): {original_mask_path}", file=sys.stderr, flush=True)
                                
                                # IMMEDIATELY add this mask to the combined PII mask
                                try:
                                    _add_mask_to_combined_pii_mask(original_mask_path, companion_folder, ctx.image_path)
                                except Exception as e:
                                    print(f"[Generative] Warning: Failed to add mask to combined PII mask: {e}", file=sys.stderr, flush=True)
                        ctx.inpainted_descriptions.append(description)
                        ctx.inpainted_description_mask_pairs.append((description, original_mask_path))
                        _update_instance_record(
                            ctx,
                            {
                                "instance_id": instance_id,
                                "description": description,
                                "mask_path": original_mask_path,  # Track original mask
                                "output_path": str(CANONICAL_OUTPUT),
                                "controlnets": controlnets,
                                "status": "processed",
                                "backend": object_backend,
                            },
                            source="inpaint",
                        )
                    
                    step = {
                        "op": "inpaint",
                        "mask_path": original_mask_path,  # Log original mask for PII tracking
                        "prompt": inpaint_prompt,  # description used for inpainting
                        "description": description,  # Same value for logging
                        "output_path": out_path,
                        "controlnets": controlnets,
                        "instance_id": instance_id,
                    }
                    ctx.steps.append(step)
                    ctx.pending_detect = None
                    
                    if out_path and Path(out_path).exists():
                        CANONICAL_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
                        shutil.copyfile(out_path, CANONICAL_OUTPUT)
                        step["output_path"] = str(CANONICAL_OUTPUT)
                        if isinstance(res, dict):
                            res["output_path"] = str(CANONICAL_OUTPUT)
                        ctx.last_output = str(CANONICAL_OUTPUT)
                except Exception as e:
                    print(f"[Generative] ERROR in object inpainting: {e}", file=sys.stderr, flush=True)
                    _update_instance_record(
                        ctx,
                        {
                            "instance_id": instance_id,
                            "description": description,
                            "status": "error",
                            "error": str(e),
                        },
                        source="inpaint",
                    )
                
                if isinstance(res, dict):
                    res.setdefault("instance_id", instance_id)
                results.append(res)
                ctx.active_instance_id = None
            
            except Exception as e:
                # Catch any unhandled exceptions (e.g., TaskGroup errors, MCP connection errors) for this instance
                error_str = str(e)
                
                # Check if it's a connection closed error (MCP server crash)
                is_connection_error = (
                    "Connection closed" in error_str or
                    "McpError" in error_str or
                    "connection" in error_str.lower()
                )
                
                if is_connection_error:
                    print(f"[Generative] MCP Server connection error for instance {idx}: Server crashed or connection lost", file=sys.stderr, flush=True)
                    print(f"[Generative] This is often caused by GPU memory exhaustion or model loading failures", file=sys.stderr, flush=True)
                else:
                    print(f"[Generative] CRITICAL ERROR processing instance {idx}: {e}", file=sys.stderr, flush=True)
                    import traceback
                    traceback.print_exc()
                
                _update_instance_record(
                    ctx,
                    {
                        "instance_id": instance_id,
                        "description": instance.get('description', 'unknown'),
                        "status": "error",
                        "error": f"{'MCP connection error (server crash)' if is_connection_error else 'Unhandled exception'}: {error_str}",
                    },
                    source="inpaint",
                )
                results.append({
                    "status": "error",
                    "description": instance.get('description', 'unknown'),
                    "error": f"{'MCP connection error (server crash)' if is_connection_error else 'Unhandled exception'}: {error_str}",
                    "instance_id": instance_id,
                })
                continue
            finally:
                ctx.active_instance_id = None
        
        # Summary
        processed_count = len([r for r in results if isinstance(r, dict) and r.get('status') != 'skipped' and r.get('status') != 'error'])
        error_count = len([r for r in results if isinstance(r, dict) and r.get('status') == 'error'])
        connection_errors = len([r for r in results if isinstance(r, dict) and r.get('status') == 'error' and 'connection' in str(r.get('error', '')).lower()])
        
        print(f"[Generative] PII object processing complete: {processed_count} processed, {skipped_count} skipped, {error_count} errors", file=sys.stderr, flush=True)
        
        if connection_errors > 0:
            print(f"[Generative] ⚠️  WARNING: {connection_errors} instances failed due to MCP server connection errors", file=sys.stderr, flush=True)
            print(f"[Generative] This typically indicates:", file=sys.stderr, flush=True)
            print(f"[Generative]   - GPU memory exhaustion (Stable Diffusion + ControlNet requires ~6-8GB VRAM)", file=sys.stderr, flush=True)
            print(f"[Generative]   - CUDA out of memory errors", file=sys.stderr, flush=True)
            print(f"[Generative]   - MCP server subprocess crash", file=sys.stderr, flush=True)
            print(f"[Generative] Suggestions:", file=sys.stderr, flush=True)
            print(f"[Generative]   - Reduce batch size (process fewer instances at once)", file=sys.stderr, flush=True)
            print(f"[Generative]   - Free GPU memory before running (kill other processes)", file=sys.stderr, flush=True)
            print(f"[Generative]   - Use smaller models (set INPAINT_USE_SDXL=false)", file=sys.stderr, flush=True)
            print(f"[Generative]   - Check nvidia-smi for memory usage", file=sys.stderr, flush=True)
        
        print(f"\n{'='*80}", file=sys.stderr, flush=True)
        print(f"[DEBUG] SUMMARY: anonymize_and_inpaint_tool completed", file=sys.stderr, flush=True)
        print(f"[DEBUG]   Total instances: {len(instances)}", file=sys.stderr, flush=True)
        print(f"[DEBUG]   Processed: {processed_count}", file=sys.stderr, flush=True)
        print(f"[DEBUG]   Skipped: {skipped_count}", file=sys.stderr, flush=True)
        print(f"[DEBUG]   Errors: {error_count}", file=sys.stderr, flush=True)
        print(f"[DEBUG]   Results breakdown:", file=sys.stderr, flush=True)
        for i, result in enumerate(results, 1):
            status = result.get('status', 'success')
            reason = result.get('reason', 'N/A')
            desc = result.get('description', 'N/A')[:50]
            print(f"[DEBUG]     {i}. Status={status}, Reason={reason}, Desc={desc}...", file=sys.stderr, flush=True)
        print(f"{'='*80}\n", file=sys.stderr, flush=True)
        
        result = {
            "results": results, 
            "processed": processed_count,
            "skipped": skipped_count,
            "errors": error_count,
            "status": "complete"
        }
        print(f"[TOOL_RESULT] anonymize_and_inpaint_tool: returning status={result['status']}, processed={processed_count}, skipped={skipped_count}, errors={error_count}", file=sys.stderr, flush=True)
        return result

    def audit_output_tool(output: str) -> dict:
        """Audit the final output for residual PII using VLM."""
        print(f"[TOOL_CALL] audit_output_tool invoked with output={output}", file=sys.stderr, flush=True)
        try:
            if getattr(ctx, "audit_max_reached", False):
                limit_result = {
                    "ok": False,
                    "error": "audit attempt limit reached",
                    "audit_attempt": ctx.audit_attempts,
                    "max_attempts": ctx.max_audit_attempts,
                    "max_attempts_reached": True,
                }
                ctx.audit = limit_result
                print(f"[TOOL_RESULT] audit_output_tool: max attempts reached", file=sys.stderr, flush=True)
                return limit_result
            final_out = str(CANONICAL_OUTPUT)
            if not final_out:
                print(f"[TOOL_RESULT] audit_output_tool: no output to audit", file=sys.stderr, flush=True)
                return {"ok": False, "error": "no output to audit"}
            print(f"[TOOL_EXEC] audit_output_tool: calling _audit_output for {final_out}", file=sys.stderr, flush=True)
            result = asyncio.run(_audit_output(ctx, str(CANONICAL_OUTPUT), final_out))
            # Store audit result in context
            ctx.audit = result
            residual_count = len(result.get('residual', [])) if isinstance(result, dict) else 0
            print(f"[TOOL_RESULT] audit_output_tool: returning ok={result.get('ok', False)}, residual_count={residual_count}", file=sys.stderr, flush=True)
            return result
        except Exception as e:
            error_result = {"ok": False, "error": f"audit failed: {e}"}
            ctx.audit = error_result
            return error_result

    def log_output_tool(image: str, output: str) -> dict:
        """Log the final output path and mark pipeline as complete."""
        print(f"[TOOL_CALL] log_output_tool invoked with image={image}, output={output}", file=sys.stderr, flush=True)
        try:
            print(f"[log] Final output: {output}", file=sys.stderr, flush=True)
            ctx.logged = True
            try:
                ctx.steps.append({
                    "op": "log_output",
                    "image": image,
                    "output_path": output,
                })
            except Exception:
                pass
            print(f"[TOOL_RESULT] log_output_tool: successfully logged", file=sys.stderr, flush=True)
            return {"ok": True, "image": image, "output": output}
        except Exception as e:
            print(f"[TOOL_RESULT] log_output_tool: failed with error={e}", file=sys.stderr, flush=True)
            return {"ok": False, "error": f"logging failed: {e}"}

    def _auto_retry_generative(groupchat) -> bool:
        """Direct fallback when the generative agent produces an empty response."""
        outstanding = ctx.pending_residual_instances or ctx.instances
        instances_to_process: List[Dict[str, Any]] = []
        if isinstance(outstanding, list):
            for item in outstanding:
                if isinstance(item, dict):
                    instances_to_process.append(copy.deepcopy(item))

        if not instances_to_process:
            print("[fallback] Generative retry requested but no outstanding instances found.", file=sys.stderr, flush=True)
            return False

        print(f"[fallback] Reissuing anonymize_and_inpaint for {len(instances_to_process)} instance(s) after empty response.", file=sys.stderr, flush=True)
        try:
            result = anonymize_and_inpaint_tool(instances_to_process)
        except Exception as err:
            fallback_msg = (
                "(auto) Fallback anonymize_and_inpaint failed with error: "
                f"{err}. Returning control to OrchestratorAgent."
            )
            groupchat.messages.append({  # type: ignore[arg-type]
                "name": "GenerativeAgent",
                "role": "assistant",
                "content": fallback_msg,
            })
            return True

        processed = result.get("processed", 0)
        skipped = result.get("skipped", 0)
        errors = result.get("errors", 0)
        outputs = sorted(
            {
                str(item.get("output_path"))
                for item in result.get("results", [])
                if isinstance(item, dict) and item.get("output_path")
            }
        )
        controlnets = sorted(
            {
                str(cn)
                for item in result.get("results", [])
                if isinstance(item, dict)
                for cn in item.get("controlnets", []) or []
                if isinstance(cn, str)
            }
        )
        summary_lines = [
            "(auto) Reissued anonymize_and_inpaint after empty generative response.",
            f"Anonymization complete: {processed} processed, {skipped} skipped, {errors} errors.",
        ]
        if outputs:
            summary_lines.append("Outputs: " + ", ".join(outputs))
        if controlnets:
            summary_lines.append("ControlNets: " + ", ".join(controlnets))
        summary_lines.append("Returning control to OrchestratorAgent.")

        groupchat.messages.append({  # type: ignore[arg-type]
            "name": "GenerativeAgent",
            "role": "assistant",
            "content": "\n".join(summary_lines),
        })
        try:
            groupchat.messages.append({  # type: ignore[arg-type]
                "name": "UserProxyAgent",
                "role": "tool",
                "content": "(auto) anonymize_and_inpaint result:\n" + json.dumps(result, ensure_ascii=False, indent=2),
            })
        except Exception:
            pass
        return True

    # Read-only context inspection tool (no side effects)
    def get_context_tool() -> dict:
        try:
            return {
                "image_path": ctx.image_path,
                "output_path": ctx.output_path,
                "last_output": ctx.last_output,
                "instances_count": len(ctx.instances or []),
                "instances": ctx.instances or [],
                "steps_count": len(ctx.steps or []),
                "steps": ctx.steps or [],
                "pending_detect": ctx.pending_detect,
                "pending_residual_instances": ctx.pending_residual_instances,
                "duplicate_checks": ctx.duplicate_checks,
                "audit": ctx.audit,
                "audit_attempts": ctx.audit_attempts,
                "max_audit_attempts": ctx.max_audit_attempts,
                "audit_max_reached": ctx.audit_max_reached,
            }
        except Exception as e:
            return {"ok": False, "error": f"get_context failed: {e}"}

    try:
        from autogen import register_function  # type: ignore

        # Note: No upload_image tool is exposed to avoid loops; the image path is provided via CLI args.

        # Auditor-owned tool (merged from Analyzer)
        register_function(
            classify_pii_tool,
            caller=auditor_agent,
            executor=user,
            name="classify_pii",
            description="Analyze image and return JSON instances. Args: image(str)",
        )

        # Note: Detection functions (segment_persons_tool, detect_license_plates_tool2, etc.)
        # are called directly in Phase 1 deterministic pre-processing, not via LLM agents.
        # No need to register them as they're never called through agent function calling.

        # Generative Agent
        register_function(
            anonymize_and_inpaint_tool,
            caller=generative_agent,
            executor=user,
            name="anonymize_and_inpaint",
            description="Anonymize and inpaint PII instances from classify_pii output. Returns {processed, skipped, errors, status:'complete'}. Automatically handles detection failures and overlaps. Args: instances(list of dicts from classify_pii)",
        )

        # Auditor tools
        register_function(
            log_output_tool,
            caller=auditor_agent,
            executor=user,
            name="log_output",
            description="Log final output path. REQUIRED Args: image (str, input image path), output (str, final output path). Call with log_output(image='...', output='...')",
        )
        register_function(
            audit_output_tool,
            caller=auditor_agent,
            executor=user,
            name="audit_output",
            description="Audit the final output for residual PII. REQUIRED Arg: output (str, path to final anonymized image). Call with audit_output(output='<canonical_output_path>')",
        )

        # Read-only context tools with unique names to avoid global override - all use executor=user for GroupChat
        register_function(get_context_tool, caller=auditor_agent, executor=user, name="get_context_auditor", description="Get pipeline context snapshot.")
    except Exception as e:
        try:
            print(f"[main][warn] tool registration encountered an error: {e}", file=sys.stderr, flush=True)
        except Exception:
            pass
        # Continue without failing early to ensure final JSON is produced
        pass

    # Intentionally no single-call pipeline tool: agents collaborate in group chat.

    # Build a GroupChat with orchestrator as planner; other agents contribute when addressed.
    from autogen import GroupChat, GroupChatManager  # type: ignore

    # Use the canonical output path in all agent instructions
    canonical_path = str(CANONICAL_OUTPUT)

    system_orchestrator = (
        "You are the OrchestratorAgent coordinating Phase 2 of an image anonymization pipeline. "
        "Phase 1 (deterministic) has already completed: persons detected/inpainted, license plates detected/blurred, traffic signs detected/masked. "
        "\n\n⚠️ CRITICAL: YOU HAVE NO TOOLS. You coordinate OTHER agents who have tools. NEVER attempt to call functions yourself.\n"
        "\n\nROUND-ROBIN FLOW (automatic speaker rotation):\n"
        "AuditorAgent → OrchestratorAgent → GenerativeAgent → AuditorAgent (loop)\n"
        "You receive control after each agent completes their task. Interpret results and guide the next agent.\n"
        "\n\nREQUIRED WORKFLOW for Phase 2:\n"
        "1. classify_pii → 2. anonymize_and_inpaint (if PII found) → 3. audit_output → 4. log_output\n"
        "SHORTCUT: If classify_pii finds NO PII and Phase 1 completed → emit 'PIPELINE COMPLETE' immediately\n"
        "\n\nYOUR ROLE:\n"
        "- Monitor tool results IN THE CONVERSATION and track workflow state: [classify_pii ✓, inpaint_pii ✓, audit ✓, log ✓]\n"
        "- When AuditorAgent returns results, analyze them and instruct the NEXT agent in round-robin\n"
        "- When GenerativeAgent completes, provide brief status (next agent gets control automatically)\n"
        "- If NO indirect PII found in classify_pii: emit 'PIPELINE COMPLETE' (Phase 1 already handled persons/plates)\n"
        "- Announce clear phase transitions with progress indicators\n"
        "- NEVER call tools yourself - you are a coordinator only\n"
        "\n\n⚠️ CRITICAL VALIDATION RULES:\n"
        "1. ONLY report success (✓) if you can SEE the tool result in conversation history\n"
        "2. If agent didn't call tool yet, instruct them to call it - don't claim it's done\n"
        "3. Look for tool execution results (JSON responses) before marking steps complete\n"
        "4. NEVER assume a tool succeeded just because an agent acknowledged - verify the result\n"
        "\n\nRETRY LOGIC:\n"
        "- If audit finds residual PII: say 'Found N residuals. GenerativeAgent, process the residual items from audit output.'\n"
        "  (GenerativeAgent will extract the 'residual' array from the tool output)\n"
        "- If no residuals OR max_attempts_reached=true: say 'Audit complete. AuditorAgent, log the output.'\n"
        "- Audit attempts capped at 3. After 3rd failed audit, proceed to logging\n"
        "- ⚠️ DO NOT manually format or stringify instance data - let GenerativeAgent read it from tool results\n"
        "\n\nCOMMUNICATION STYLE - BE CONCISE:\n"
        "- Give DIRECT instructions to the NEXT agent in round-robin ONLY\n"
        "- DO NOT repeat or echo what other agents said\n"
        "- ROUND-ROBIN ORDER: Your next message goes to GenerativeAgent (unless workflow complete)\n"
        "- Keep messages under 3 sentences\n"
        "- ⚠️ NEVER call functions - just describe what should happen\n"
        "- ⚠️ NEVER copy/paste instance data - reference tool outputs instead\n"
        "- ⚠️ ONLY use checkmarks (✓) when you've VERIFIED tool execution in conversation\n"
        "\n\nCOMMUNICATION EXAMPLES (text only - NO function calls):\n"
        f"- CORRECT (after seeing tool result): 'Phase 2: [classify_pii ✓] Found X instances. GenerativeAgent, anonymize the instances from classify_pii output.'\n"
        f"- WRONG (no tool result seen): 'Phase 2: [classify_pii ✓] Found 2 instances.' ← DON'T claim success without verification!\n"
        f"- After classify_pii (no PII): 'Phase 2: [classify_pii ✓] No indirect PII found. PIPELINE COMPLETE'\n"
        f"- After anonymize_and_inpaint: 'Phase 2: [inpaint_pii ✓] Anonymization complete. (AuditorAgent gets control)'\n"
        "- After audit (residuals found): 'Phase 2: [audit ✗] Found N residuals. GenerativeAgent, reprocess the residual items from audit output.'\n"
        f"- After audit (passed): 'Phase 2: [audit ✓] No residual PII. AuditorAgent, log the output.'\n"
        "- After log_output seen: Emit exactly 'PIPELINE COMPLETE' (no additional text)\n"
        "\n\nCONTEXT:\n"
        "- Phase 1 already handled persons, license plates, traffic signs\n"
        "- Focus on indirect PII requiring vision analysis (text, signs, logos in private spaces)\n"
    )
    orchestrator.update_system_message(system_orchestrator)

    # Analyzer merged into Auditor
    # ImageModificationAgent removed; blur handled by TechnicianAgent.
    generative_agent.update_system_message(
        "You are the GenerativeAgent responsible for anonymizing PII via inpainting. "
        "Tool: anonymize_and_inpaint\n"
        "\n\n⚠️ CRITICAL EXECUTION RULES:\n"
        "1. When instructed to anonymize, YOU MUST CALL anonymize_and_inpaint in your response\n"
        "2. NEVER just acknowledge without calling the tool\n"
        "3. Tool call is a JSON function call, not a text description\n"
        "4. Extract instances from previous tool output (classify_pii or audit_output)\n"
        "\n\nROUND-ROBIN: You receive control after OrchestratorAgent. Execute the task, then control passes to AuditorAgent.\n"
        "\n\nEXECUTION WORKFLOW:\n"
        "1. Look at the most recent tool output in the conversation history\n"
        "   - If classify_pii was called: find the JSON output and extract the 'instances' array\n"
        "   - If audit_output was called: find the JSON output and extract the 'residual' array\n"
        "2. Pass each dict object from that array as a separate element\n"
        "3. Call anonymize_and_inpaint with the array of dict objects\n"
        "4. Report results BRIEFLY: 'Processed X items.'\n"
        "\n\n⚠️ CRITICAL DATA FORMAT - COMMON MISTAKES:\n"
        "   ✓ CORRECT (array of dict objects as JSON):\n"
        "     anonymize_and_inpaint(instances=[\n"
        "       {\"det_prompt\": \"van with text\", \"description\": \"van\", \"bbox\": [308, 200, 564, 567]},\n"
        "       {\"det_prompt\": \"blue sign\", \"description\": \"sign\", \"bbox\": [215, 256, 294, 42]}\n"
        "     ])\n"
        "\n"
        "   ✗ WRONG (single string containing all dicts - THIS IS THE MOST COMMON ERROR):\n"
        "     anonymize_and_inpaint(instances=[\n"
        "       \"{'det_prompt': 'van', 'bbox': [...]}, {'det_prompt': 'sign', 'bbox': [...]}\"\n"
        "     ])\n"
        "     → DO NOT stringify the array! Pass actual JSON dict objects!\n"
        "\n"
        "   ✗ WRONG (stringified dict objects):\n"
        "     anonymize_and_inpaint(instances=[\"{'det_prompt': '...', 'bbox': [...]}\"])\n"
        "     → Each element must be a dict object, not a string\n"
        "\n\nEXAMPLE EXTRACTION:\n"
        "Given audit_output returns:\n"
        "  {'residual': [{'det_prompt': 'van', 'bbox': [308, 200, 564, 567]}, {'det_prompt': 'sign', 'bbox': [215, 256, 294, 42]}]}\n"
        "\n"
        "YOU MUST call:\n"
        "  anonymize_and_inpaint(instances=[\n"
        "    {\"det_prompt\": \"van\", \"bbox\": [308, 200, 564, 567]},\n"
        "    {\"det_prompt\": \"sign\", \"bbox\": [215, 256, 294, 42]}\n"
        "  ])\n"
        "\n"
        "NOT this:\n"
        "  anonymize_and_inpaint(instances=[\"{'det_prompt': 'van', 'bbox': [308, 200, 564, 567]}, {'det_prompt': 'sign', 'bbox': [215, 256, 294, 42]}\"])\n"
        "\n\nBE CONCISE:\n"
        "- DO NOT repeat OrchestratorAgent's instructions\n"
        "- DO NOT tell other agents what to do\n"
        "- DO NOT wait for instructions - if OrchestratorAgent told you to do something, DO IT\n"
        "- Keep responses under 1 sentence AFTER tool execution\n"
        "\n\nIMPORTANT:\n"
        "- Report EXACTLY what tool returns - don't hallucinate instances\n"
        "- Only call one tool per turn, then stop\n"
        "- Focus classify_pii on private property only (persons/plates/signs already handled)\n"
        "- If you don't call the tool when instructed, the workflow will fail\n"
    )

    auditor_agent.update_system_message(
        "You are the AuditorAgent responsible for PII detection, quality assurance, and logging. "
        "Tools: classify_pii, audit_output, log_output\n"
        "\n\n⚠️ CRITICAL EXECUTION RULES:\n"
        "1. When instructed to use a tool, YOU MUST CALL IT in your response\n"
        "2. NEVER just acknowledge without calling the tool\n"
        "3. Each response should contain EXACTLY ONE tool call\n"
        "4. Tool calls are JSON function calls, not text descriptions\n"
        "\n\nROUND-ROBIN: You receive control after GenerativeAgent completes OR at workflow start. Execute your task, then control passes to OrchestratorAgent.\n"
        "\n\nYOUR TASKS:\n"
        "1. classify_pii: Detect indirect PII in private spaces (text on windows, house numbers, personal items visible indoors)\n"
        "   - Call with: classify_pii(image='<image_path>')\n"
        "2. audit_output: Verify no residual PII remains after anonymization\n"
        f"   - Call with: audit_output(output='{canonical_path}')\n"
        "3. log_output: Record final results\n"
        f"   - Call with: log_output(image='<input_path>', output='{canonical_path}')\n"
        "\n\nEXAMPLE WORKFLOW:\n"
        "When OrchestratorAgent says: 'AuditorAgent: Please classify any remaining PII'\n"
        "YOU MUST respond with the tool call (not text explanation):\n"
        "  classify_pii(image='artifacts/data/CityScapes/.../image.png')\n"
        "\n\nREPORTING - BE CONCISE:\n"
        "After tool execution, provide brief summary:\n"
        "- After classify_pii: 'Found X instances.' (tool output visible to OrchestratorAgent)\n"
        "- After audit_output: 'Audit passed.' OR 'Audit failed: X residuals.'\n"
        "- After log_output: 'Logged.'\n"
        "\n\nBE CONCISE:\n"
        "- DO NOT repeat instructions from OrchestratorAgent\n"
        "- DO NOT tell other agents what to do\n"
        "- DO NOT wait for instructions - if OrchestratorAgent told you to do something, DO IT\n"
        "- Keep responses under 1 sentence AFTER tool execution\n"
        "\n\nIMPORTANT:\n"
        "- Report EXACTLY what tool returns - don't hallucinate instances\n"
        "- Only call one tool per turn, then stop\n"
        "- Focus classify_pii on private property only (persons/plates/signs already handled)\n"
        "- If you don't call the tool when instructed, the workflow will fail\n"
    )

    # Round-robin agent order: auditor -> orchestrator -> generative -> (back to auditor)
    # Note: UserProxyAgent is excluded from rotation (only handles tool execution)
    round_robin_agents = [auditor_agent, orchestrator, generative_agent]
    
    # Custom speaker selection with round-robin pattern
    def _round_robin_speaker_selection(last_speaker, groupchat):  # type: ignore
        """
        Speaker selection implementing round-robin: auditor -> orchestrator -> generative -> auditor -> ...
        
        Round-robin order (excluding UserProxyAgent):
        1. AuditorAgent -> UserProxyAgent (if tool call) -> OrchestratorAgent
        2. OrchestratorAgent -> UserProxyAgent (if tool call) -> GenerativeAgent
        3. GenerativeAgent -> UserProxyAgent (if tool call) -> AuditorAgent (loop back)
        
        Special cases:
        - After agent makes tool call -> UserProxyAgent executes it
        - After UserProxyAgent returns results -> continue to next in round-robin
        - Empty message loops -> skip to next in rotation
        """
        try:
            last_name = getattr(last_speaker, "name", "unknown")
            
            # Check if the last message from an agent contains a tool call
            def has_pending_tool_call():
                if not groupchat.messages:
                    return False
                last_msg = groupchat.messages[-1]
                if isinstance(last_msg, dict):
                    # Check for tool_calls in the message
                    has_calls = bool(last_msg.get("tool_calls"))
                    # Also check if the message has function_call (older format)
                    has_function = bool(last_msg.get("function_call"))
                    return has_calls or has_function
                return False
            
            # If last speaker was an agent in rotation and made a tool call, execute it first
            if last_name in [getattr(a, "name", "") for a in round_robin_agents]:
                if has_pending_tool_call():
                    print(f"[speaker_selection] {last_name} made tool call, routing to UserProxyAgent for execution", file=sys.stderr, flush=True)
                    return user  # UserProxyAgent executes the tool
            
            # After tool execution, continue the round-robin from where we left off
            if last_name == "UserProxyAgent":
                # Find the agent who requested the tool (look back for last non-UserProxy speaker)
                requesting_agent = None
                for msg in reversed(groupchat.messages[:-1]):  # Skip the last UserProxy message
                    msg_name = msg.get("name", "") if isinstance(msg, dict) else ""
                    if msg_name != "UserProxyAgent":
                        requesting_agent = msg_name
                        break
                
                if requesting_agent:
                    # Find this agent in rotation and get next
                    for i, agent in enumerate(round_robin_agents):
                        if getattr(agent, "name", "") == requesting_agent:
                            next_idx = (i + 1) % len(round_robin_agents)
                            next_agent = round_robin_agents[next_idx]
                            print(f"[speaker_selection] Tool execution complete, continuing round-robin: {requesting_agent} -> {getattr(next_agent, 'name', 'unknown')}", file=sys.stderr, flush=True)
                            return next_agent
                
                # Fallback: start from beginning of rotation
                print(f"[speaker_selection] After tool execution, restarting round-robin from AuditorAgent", file=sys.stderr, flush=True)
                return auditor_agent
            
            # Detect empty message loops
            try:
                if len(groupchat.messages) >= 3:
                    recent_msgs = groupchat.messages[-3:]
                    empty_count = 0
                    same_speaker_empty = 0
                    
                    for msg in recent_msgs:
                        content = msg.get("content", "") if isinstance(msg, dict) else ""
                        msg_name = msg.get("name", "") if isinstance(msg, dict) else ""
                        has_tool_calls = bool(msg.get("tool_calls")) if isinstance(msg, dict) else False
                        
                        # A message is empty only if it has no content AND no tool calls
                        is_empty = (not content or content.strip() == "") and not has_tool_calls
                        
                        if is_empty and msg_name == "GenerativeAgent":
                            try:
                                triggered = _auto_retry_generative(groupchat)
                                if triggered:
                                    print("[speaker_selection] Empty generative response detected; fallback executed.", file=sys.stderr, flush=True)
                                    # After retry, continue to next in rotation
                            except Exception as auto_err:
                                print(f"[speaker_selection] Fallback generative retry failed: {auto_err}", file=sys.stderr, flush=True)

                        if is_empty:
                            empty_count += 1
                            if msg_name == last_name:
                                same_speaker_empty += 1
                    
                    # If we see 2+ empty messages or same speaker sent empty message twice, skip to next in rotation
                    if empty_count >= 2 or same_speaker_empty >= 2:
                        print(f"[speaker_selection] Detected empty message loop from {last_name}, skipping to next in round-robin", file=sys.stderr, flush=True)
                        # Continue to round-robin logic below
            except Exception:
                pass
            
            # Standard round-robin: find current agent and select next
            # (Only reached if no tool call is pending and not UserProxyAgent)
            for i, agent in enumerate(round_robin_agents):
                if getattr(agent, "name", "") == last_name:
                    next_idx = (i + 1) % len(round_robin_agents)
                    next_agent = round_robin_agents[next_idx]
                    next_name = getattr(next_agent, "name", "unknown")
                    print(f"[speaker_selection] Round-robin: {last_name} -> {next_name}", file=sys.stderr, flush=True)
                    return next_agent
            
            # Fallback: if speaker is not in rotation list, start from beginning
            print(f"[speaker_selection] Unknown speaker '{last_name}', starting round-robin from AuditorAgent", file=sys.stderr, flush=True)
            return auditor_agent
        except Exception as e:
            print(f"[speaker_selection] Error in round-robin selection: {e}, defaulting to AuditorAgent", file=sys.stderr, flush=True)
            return auditor_agent
    
    group = GroupChat(
        agents=[user, orchestrator, generative_agent, auditor_agent],
        messages=[],
        speaker_selection_method=_round_robin_speaker_selection,
        max_round=100
    )

    # Terminate the group chat as soon as any message contains the sentinel.
    def _termination_pred(msg):  # type: ignore
        try:
            content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", "")
            return isinstance(content, str) and COMPLETION_SENTINEL in content
        except Exception:
            return False

    # Refined termination: only terminate when OrchestratorAgent emits a line that equals the sentinel
    def _is_termination_msg(msg):  # type: ignore
        try:
            name = msg.get("name") if isinstance(msg, dict) else getattr(msg, "name", "")
            content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", "")
            # Primary termination: orchestrator emits the sentinel (with or without punctuation)
            if name == "OrchestratorAgent" and isinstance(content, str):
                # Check if content contains the sentinel (case-insensitive, allow punctuation)
                content_upper = content.upper()
                if COMPLETION_SENTINEL in content_upper or COMPLETION_SENTINEL.rstrip('.') in content_upper:
                    return True
            # Fallback termination: after successful audit and successful log
            try:
                a = getattr(ctx, "audit", None)
                logged = bool(getattr(ctx, "logged", False))
                audit_ok = False
                if isinstance(a, dict):
                    audit_ok = bool(a.get("ok") is True or a.get("success") is True)
                if audit_ok and logged:
                    return True
            except Exception:
                pass
            return False
        except Exception:
            return False

    manager = GroupChatManager(
        groupchat=group,
        llm_config=_llm_config(),
        is_termination_msg=_is_termination_msg,
    )
    manager.max_rounds = 100  # Increased to allow for audit retry loops

    # ============================================================================
    # DETERMINISTIC PRE-PROCESSING: PERSONS, AND LICENSE PLATES
    # Execute deterministically WITHOUT LLM orchestration (fast, reliable)
    # ============================================================================
    
    # Check if Phase 1 or Phase 2 should be skipped
    skip_phase1 = os.getenv("SKIP_PHASE1", "").lower() in ("1", "true", "yes")
    skip_phase2 = os.getenv("SKIP_PHASE2", "").lower() in ("1", "true", "yes")
    
    if not skip_phase1:
        print("[Pipeline] ========================================", file=sys.stderr, flush=True)
        print("[Pipeline] PHASE 1: DETERMINISTIC PRE-PROCESSING", file=sys.stderr, flush=True)
        print("[Pipeline] ========================================", file=sys.stderr, flush=True)
    t_workflow_start = perf_counter()
    
    try:
        # PHASE 1: Only run if not skipped
        if not skip_phase1:
            # STEP 1: Detect persons (deterministic YOLO detection)
            if skip_phase2:
                print("[Pipeline] Step 1/2: Detecting persons...", file=sys.stderr, flush=True)
            else:
                print("[Pipeline] Step 1/5: Detecting persons...", file=sys.stderr, flush=True)
            persons_result = segment_persons_tool(image=str(CANONICAL_OUTPUT))
            person_count = persons_result.get('meta', {}).get('count', 0)
            print(f"[Pipeline] Found {person_count} person(s)", file=sys.stderr, flush=True)
        else:
            person_count = 0
        
        # STEP 2: Anonymize persons (sequential inpainting, one at a time)
        if not skip_phase1 and person_count > 0:
            if skip_phase2:
                print(f"[Pipeline] Step 2/2: Anonymizing {person_count} person(s) sequentially...", file=sys.stderr, flush=True)
            else:
                print(f"[Pipeline] Step 2/5: Anonymizing {person_count} person(s) sequentially...", file=sys.stderr, flush=True)
            persons_inpaint_result = anonymize_and_inpaint_persons_tool()
            processed = persons_inpaint_result.get('processed', 0)
            total = persons_inpaint_result.get('total', 0)
            print(f"[Pipeline] Person anonymization complete: {processed}/{total} processed", file=sys.stderr, flush=True)
        
        # STEP 3-5: Only process traffic signs and license plates if Phase 1 and Phase 2 are enabled
        if not skip_phase1 and not skip_phase2:
            # STEP 3: Detect traffic signs (using grounded sam 2)
            print("[Pipeline] Step 3/5: Detecting traffic signs...", file=sys.stderr, flush=True)
            signs_result = detect_traffic_signs_tool(image=str(CANONICAL_OUTPUT))
            sign_instances = signs_result.get('instances', [])
            print(f"[Pipeline] Found {len(sign_instances)} traffic sign(s)", file=sys.stderr, flush=True)
            
            # STEP 4: Detect license plates (deterministic YOLO detection)
            print("[Pipeline] Step 4/5: Detecting license plates...", file=sys.stderr, flush=True)
            plates_result = detect_license_plates_tool(image=str(CANONICAL_OUTPUT))
            plate_instances = plates_result.get('instances', [])
            print(f"[Pipeline] Found {len(plate_instances)} license plate(s)", file=sys.stderr, flush=True)
            
            # STEP 5: Blur license plates (deterministic Gaussian blur)
            if plate_instances:
                print(f"[Pipeline] Step 5/5: Blurring {len(plate_instances)} license plate(s)...", file=sys.stderr, flush=True)
                for i, plate in enumerate(plate_instances):
                    mask_path = plate.get('mask_path')
                    if mask_path:
                        blur_result = apply_blur_tool(image=str(CANONICAL_OUTPUT), mask_path=mask_path)
                        if blur_result.get("output_path"):
                            print(f"[Pipeline] Blurred plate {i+1}/{len(plate_instances)}", file=sys.stderr, flush=True)
                        else:
                            print(f"[Pipeline] Failed to blur plate {i+1}/{len(plate_instances)}: {blur_result.get('error', 'unknown error')}", file=sys.stderr, flush=True)
        
        t_deterministic_end = perf_counter()
        if not skip_phase1:
            print(f"[Pipeline] Deterministic pre-processing complete in {t_deterministic_end - t_workflow_start:.1f}s", file=sys.stderr, flush=True)
        else:
            print("[Pipeline] PHASE 1: SKIPPED (phase2-only mode)", file=sys.stderr, flush=True)
        
        # ============================================================================
        # AGENTIC WORKFLOW: PII CLASSIFICATION AND QUALITY ASSURANCE
        # Use LLM vision for complex PII detection and audit (requires intelligence)
        # ============================================================================
        
        if skip_phase2:
            print("[Pipeline] ========================================", file=sys.stderr, flush=True)
            print("[Pipeline] PHASE 2: SKIPPED (persons-only mode)", file=sys.stderr, flush=True)
            print("[Pipeline] ========================================", file=sys.stderr, flush=True)
        else:
            print("[Pipeline] ========================================", file=sys.stderr, flush=True)
            print("[Pipeline] PHASE 2: AGENTIC PII & QA WORKFLOW", file=sys.stderr, flush=True)
            print("[Pipeline] ========================================", file=sys.stderr, flush=True)
            
            # Initiate agentic workflow for Phase 2 - start with AuditorAgent
            # Note: We initiate from the user/manager so round-robin starts correctly with AuditorAgent
            if skip_phase1:
                initial_message = (
                    f"Phase 1 was skipped (phase2-only mode). "
                    f"AuditorAgent: Please classify all PII in the image '{str(CANONICAL_OUTPUT)}' using classify_pii. "
                    "OrchestratorAgent will coordinate subsequent steps after you report your findings."
                )
            else:
                initial_message = (
                    f"Phase 1 (deterministic pre-processing) is complete. Persons, and license plates have been detected and processed. "
                    f"AuditorAgent: Please classify any remaining PII in the image '{str(CANONICAL_OUTPUT)}' using classify_pii. "
                    "OrchestratorAgent will coordinate subsequent steps after you report your findings."
                )
            
            print("[Pipeline] Initiating agentic workflow...", file=sys.stderr, flush=True)
            # Start conversation from user so AuditorAgent gets first turn in round-robin
            chat_result = user.initiate_chat(
                manager, 
                message=initial_message,
                max_round=80
            )

            forced_completion = False
            if ctx.audit_max_reached and not ctx.logged:
                print("[Pipeline] Audit attempt limit reached; forcing final logging.", file=sys.stderr, flush=True)
                try:
                    log_output_tool(ctx.image_path, str(CANONICAL_OUTPUT))
                except Exception as forced_log_err:
                    print(f"[Pipeline] WARN: Forced log_output failed: {forced_log_err}", file=sys.stderr, flush=True)
                else:
                    forced_completion = True
            if ctx.audit_max_reached:
                sentinel_present = False
                try:
                    sentinel_present = any(
                        isinstance(msg, dict)
                        and isinstance(msg.get("content"), str)
                        and COMPLETION_SENTINEL in msg.get("content", "")
                        for msg in manager.groupchat.messages  # type: ignore[arg-type]
                    )
                except Exception:
                    sentinel_present = False
                if not sentinel_present:
                    manager.groupchat.messages.append({  # type: ignore[arg-type]
                        "name": "OrchestratorAgent",
                        "role": "assistant",
                        "content": COMPLETION_SENTINEL,
                    })
                    print(COMPLETION_SENTINEL, file=sys.stderr, flush=True)
                elif forced_completion:
                    print(COMPLETION_SENTINEL, file=sys.stderr, flush=True)
            
            # Extract results from chat (this may need adjustment based on how results are stored)
            # For now, assume the chat completes and we can proceed to final logging
            print("[Pipeline] Agentic workflow complete", file=sys.stderr, flush=True)
        
    except Exception as e:
        print(f"[Pipeline] ERROR: {e}", file=sys.stderr, flush=True)
        import traceback
        traceback.print_exc()
    
    t_workflow_end = perf_counter()
    print(f"[Pipeline] Workflow completed in {t_workflow_end - t_workflow_start:.1f}s", file=sys.stderr, flush=True)

    # Build final JSON log deterministically from captured tool results
    final_json: Dict[str, Any] | None = None
    try:
        built_output = ctx.last_output
        # Build comprehensive instance summary from tracked records
        if ctx.instance_records:
            built_instances: List[Dict[str, Any]] = []
            for record in ctx.instance_records.values():
                cleaned = copy.deepcopy(record)
                if isinstance(cleaned.get("sources"), list):
                    cleaned["sources"] = sorted(set(cleaned["sources"]))
                built_instances.append(cleaned)
            built_instances.sort(key=lambda rec: (
                rec.get("instance_id") or "",
                rec.get("description") or "",
            ))
        else:
            built_instances = copy.deepcopy(ctx.instances or [])

        # Fill in missing status information from workflow steps when possible
        if built_instances:
            fallback_status: Dict[str, str] = {}
            for step in ctx.steps or []:
                inst_id = step.get("instance_id") or step.get("description")
                if not inst_id:
                    continue
                if step.get("op") == "inpaint":
                    fallback_status[inst_id] = "processed"
            for instance in built_instances:
                if not instance.get("status"):
                    inst_id = instance.get("instance_id") or instance.get("description")
                    fb_status = fallback_status.get(inst_id)
                    if fb_status:
                        instance["status"] = fb_status
        
        built_steps = ctx.steps or []
        built_history = list(ctx.audit_history or [])
        if ctx.audit:
            built_audit = ctx.audit
        elif built_history:
            built_audit = built_history[-1]
        else:
            built_audit = {"ok": False, "error": "no audit"}

        # Collect configuration information about the agentic AI system
        use_gemini = os.getenv("USE_GEMINI", "false").lower() in ("true", "1", "yes")
        use_openai = os.getenv("USE_OPENAI", "false").lower() in ("true", "1", "yes")
        use_gemini_vision = os.getenv("USE_GEMINI_VISION", "false").lower() in ("true", "1", "yes")
        # Only use Ollama vision if Gemini vision is not enabled (mutual exclusion)
        use_ollama_vision = (not use_gemini_vision) and os.getenv("USE_OLLAMA_VISION", "false").lower() in ("true", "1", "yes")
        
        # Base LLM configuration (shared by all agents)
        # Priority: Gemini > OpenAI > Ollama (default)
        if use_gemini:
            llm_provider = "gemini"
            llm_model = os.getenv("AUTOGEN_MODEL", "gemini-2.0-flash-exp")
            llm_timeout = int(os.getenv("AUTOGEN_TIMEOUT", "300"))
            llm_base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
        elif use_openai:
            llm_provider = "openai"
            llm_model = os.getenv("AUTOGEN_MODEL", "gpt-4o-mini")
            llm_timeout = int(os.getenv("AUTOGEN_TIMEOUT", "120"))
            llm_base_url = None
        else:
            llm_provider = "ollama"
            llm_model = os.getenv("AUTOGEN_MODEL", "mistral-small:24b")
            llm_timeout = int(os.getenv("AUTOGEN_TIMEOUT", "300"))
            llm_base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
        
        # Vision model configuration (used by Analyzer/Auditor for PII classification)
        # Priority: Gemini > Ollama > OpenAI (fallback)
        if use_gemini_vision:
            vision_provider = "gemini"
            vision_model = os.getenv("GEMINI_VISION_MODEL", "gemini-2.5-pro")
            vision_base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
        elif use_ollama_vision:
            vision_provider = "ollama"
            vision_model = os.getenv("OLLAMA_VISION_MODEL", "qwen2.5vl:32b")
            vision_base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
        else:
            vision_provider = "openai"
            vision_model = os.getenv("OPENAI_ANALYZER_MODEL", "gpt-4o-mini")
            vision_base_url = None
        
        # Only set vision seed if explicitly configured (for reproducibility)
        vision_seed = None
        vision_seed_env = os.getenv("OLLAMA_VISION_SEED" if use_ollama_vision else "OPENAI_ANALYZER_SEED")
        if vision_seed_env is not None:
            try:
                vision_seed = int(vision_seed_env)
            except ValueError:
                vision_seed = None

        person_backend = "sdxl" if _truthy_env("INPAINT_USE_SDXL", True) else "sd15"
        object_backend = "sdxl" if _truthy_env("INPAINT_OBJECT_USE_SDXL", True) else "sd15"
        person_model = "diffusers/stable-diffusion-xl-1.0-inpainting-0.1" if person_backend == "sdxl" else "runwayml/stable-diffusion-v1-5"
        object_model = "diffusers/stable-diffusion-xl-1.0-inpainting-0.1" if object_backend == "sdxl" else "runwayml/stable-diffusion-v1-5"
        person_controlnets = [
            "thibaud/controlnet-openpose-sdxl-1.0"
        ] if person_backend == "sdxl" else [
            "lllyasviel/sd-controlnet-openpose"
        ]
        object_controlnets = [
            "diffusers/controlnet-canny-sdxl-1.0"
        ] if object_backend == "sdxl" else [
            "lllyasviel/sd-controlnet-canny"
        ]
        person_resolution = int(os.getenv("INPAINT_SDXL_RESOLUTION" if person_backend == "sdxl" else "INPAINT_RESOLUTION", "1024" if person_backend == "sdxl" else "768"))
        object_resolution = int(os.getenv("INPAINT_SDXL_RESOLUTION" if object_backend == "sdxl" else "INPAINT_RESOLUTION", "1024" if object_backend == "sdxl" else "768"))
        
        config_info: Dict[str, Any] = {
            "llm": {
                "provider": llm_provider,
                "model": llm_model,
                "timeout": llm_timeout,
                "base_url": llm_base_url,
                "temperature": 0,
            },
            "agents": {
                "orchestrator": {
                    "name": "OrchestratorAgent",
                    "type": "ConversableAgent",
                    "llm": {"provider": llm_provider, "model": llm_model},
                    "tools": [],  
                    "role": "Coordinates workflow, routes tasks to appropriate agents"
                },
                "generative": {
                    "name": "GenerativeAgent",
                    "type": "AssistantAgent",
                    "llm": {"provider": llm_provider, "model": llm_model},
                    "tools": [
                        "anonymize_and_inpaint",
                    ],
                    "role": "Anonymizes and inpaints PII using Stable Diffusion with ControlNets"
                },
                "auditor": {
                    "name": "AuditorAgent",
                    "type": "AssistantAgent",
                    "llm": {"provider": llm_provider, "model": llm_model},
                    "tools": [
                        "classify_pii",
                        "audit_output",
                        "log_output",
                    ],
                    "role": "Classifies PII, audits output for residual PII, logs results",
                    "vision": {"provider": vision_provider, "model": vision_model}
                }
            },
            "backends": {
                "detector": {
                    "backend": os.getenv("DETECTOR_BACKEND", "grounded-sam-2"),
                    "grounded_sam2_dir": os.getenv("GROUNDED_SAM2_DIR", "models/Grounded-SAM-2"),
                },
                "inpainter": {
                    "person": {
                        "backend": person_backend,
                        "model": person_model,
                        "resolution": person_resolution,
                        "strength": float(os.getenv("INPAINT_STRENGTH", "0.9")),
                        "controlnets": person_controlnets,
                    },
                    "object": {
                        "backend": object_backend,
                        "model": object_model,
                        "resolution": object_resolution,
                        "strength": float(os.getenv("INPAINT_STRENGTH", "0.9")),
                        "controlnets": object_controlnets,
                    },
                },
                "vision": {
                    "provider": vision_provider,
                    "model": vision_model,
                    "base_url": vision_base_url,
                    **({"seed": vision_seed} if vision_seed is not None else {}),
                },
            }
        }

    # User-provided output path is ignored - we always use canonical structure
    # Output is always: artifacts/{relative_parent}/{input_stem}{input_suffix}

        final_json = {
            "image": image_path,
            "output": built_output or output_path,
            "instances": built_instances,
            "steps": built_steps,
            "audit": built_audit,
            "audit_history": built_history,
            "config": config_info,
            "timing": {
                "workflow_seconds": round(t_workflow_end - t_workflow_start, 3),
                "main_seconds": None,  # set below after all processing
            },
        }
    except Exception as _e:
        final_json = {
            "image": image_path,
            "output": output_path,
            "instances": [],
            "steps": [],
            "audit": {"ok": False, "error": f"summary build failed: {_e}"},
            "audit_history": list(getattr(ctx, "audit_history", []) or []),
        }

    # Logging and transcript output removed

    # Optional cleanup of temporary artifacts
    try:
        do_cleanup = bool(args.cleanup_tmp or os.getenv("AUTOGEN_CLEANUP_TMP"))
    except Exception:
        do_cleanup = False
    if do_cleanup and isinstance(final_json, dict):
        masks, blurs = _collect_tmp_files(final_json.get("steps") or [], final_json.get("output"))
        _ = _cleanup_tmp(masks, blurs)

    # Set total main duration just before printing
    try:
        if isinstance(final_json, dict):
            final_json.setdefault("timing", {})
            final_json["timing"]["main_seconds"] = round(perf_counter() - t_main_start, 3)
    except Exception:
        pass

    # ============================================================================
    # FINALIZE COMBINED PII MASK
    # The mask has been built incrementally as each PII region was detected/anonymized
    # ============================================================================
    print(f"\n{'='*80}", file=sys.stderr, flush=True)
    print(f"[PII Mask] Finalizing combined PII mask...", file=sys.stderr, flush=True)
    print(f"{'='*80}", file=sys.stderr, flush=True)
    
    # The combined PII mask was built incrementally - just verify it exists and add to JSON
    try:
        input_stem = Path(ctx.image_path).stem
        masks_dir = companion_folder / "masks"
        pii_mask_path = masks_dir / f"{input_stem}_pii_mask.png"
        
        if pii_mask_path.exists():
            from PIL import Image
            import numpy as np
            
            # Load and verify the mask
            combined_mask_img = Image.open(pii_mask_path).convert('L')
            combined_mask_arr = np.array(combined_mask_img) > 127
            
            # Calculate statistics
            masked_pixels = combined_mask_arr.sum()
            total_pixels = combined_mask_arr.size
            coverage_pct = 100 * masked_pixels / total_pixels if total_pixels > 0 else 0
            
            print(f"[PII Mask] ✓ Combined PII mask: {masked_pixels:,} pixels ({coverage_pct:.2f}% coverage)", file=sys.stderr, flush=True)
            print(f"[PII Mask] ✓ Saved at: {pii_mask_path}", file=sys.stderr, flush=True)
            
            # Add mask path to JSON log
            if isinstance(final_json, dict):
                final_json['pii_mask'] = str(pii_mask_path)
                print(f"[PII Mask] ✓ Added pii_mask field to JSON output", file=sys.stderr, flush=True)
        else:
            print(f"[PII Mask] ⚠️  No PII mask found (no PII detected)", file=sys.stderr, flush=True)
            
            # Create an empty mask as placeholder
            if CANONICAL_OUTPUT and CANONICAL_OUTPUT.exists():
                from PIL import Image
                import numpy as np
                
                img = Image.open(str(CANONICAL_OUTPUT)).convert('RGB')
                img_width, img_height = img.size
                combined_mask = np.zeros((img_height, img_width), dtype=bool)
                
                # Save empty mask
                combined_mask_uint8 = (combined_mask.astype(np.uint8) * 255)
                combined_mask_img = Image.fromarray(combined_mask_uint8, mode='L')
                
                masks_dir.mkdir(parents=True, exist_ok=True)
                pii_mask_path = masks_dir / f"{input_stem}_pii_mask.png"
                combined_mask_img.save(pii_mask_path)
                
                if isinstance(final_json, dict):
                    final_json['pii_mask'] = str(pii_mask_path)
                
                print(f"[PII Mask] ✓ Created empty PII mask: {pii_mask_path}", file=sys.stderr, flush=True)
    except Exception as e:
        print(f"[PII Mask] ERROR: Failed to create combined PII mask: {e}", file=sys.stderr, flush=True)
        import traceback
        traceback.print_exc()
    
    print(f"{'='*80}\n", file=sys.stderr, flush=True)

    # Save JSON log in companion folder (includes pii_mask field if created successfully)
    try:
        if isinstance(final_json, dict) and CANONICAL_OUTPUT:
            json_log_path = companion_folder / f"{input_stem}.json"
            with json_log_path.open('w', encoding='utf-8') as f:
                json.dump(final_json, f, ensure_ascii=False, indent=2)
            print(f"[log] Saved JSON to {json_log_path}", file=sys.stderr, flush=True)
    except Exception as e:
        print(f"[log] Failed to save JSON log: {e}", file=sys.stderr, flush=True)

    # Print JSON to stdout for backward compatibility
    print(json.dumps(final_json, ensure_ascii=False, indent=2))
    
    # ============================================================================
    # FILE ORGANIZATION SUMMARY
    # ============================================================================
    print(f"\n{'='*80}", file=sys.stderr, flush=True)
    print(f"[File Organization]", file=sys.stderr, flush=True)
    print(f"{'='*80}", file=sys.stderr, flush=True)
    print(f"Anonymized image: {CANONICAL_OUTPUT}", file=sys.stderr, flush=True)
    print(f"Companion folder: {companion_folder}/", file=sys.stderr, flush=True)
    
    masks_dir = companion_folder / "masks"
    if masks_dir.exists():
        mask_files = list(masks_dir.glob('*.png'))
        print(f"  ├── masks/ ({len(mask_files)} file(s))", file=sys.stderr, flush=True)
        individual_masks = [m for m in mask_files if not m.name.endswith('_pii_mask.png')]
        pii_masks = [m for m in mask_files if m.name.endswith('_pii_mask.png')]
        if individual_masks:
            print(f"  │   ├── Individual masks: {len(individual_masks)} file(s)", file=sys.stderr, flush=True)
        if pii_masks:
            print(f"  │   └── Combined PII mask: {pii_masks[0].name}", file=sys.stderr, flush=True)
    
    log_path = companion_folder / f"{input_stem}.log"
    if log_path.exists():
        print(f"  ├── {input_stem}.log", file=sys.stderr, flush=True)
    
    json_path = companion_folder / f"{input_stem}.json"
    if json_path.exists():
        print(f"  └── {input_stem}.json", file=sys.stderr, flush=True)
    
    print(f"\nTemporary crops: artifacts/tmp/crops/ (can be deleted)", file=sys.stderr, flush=True)
    print(f"{'='*80}\n", file=sys.stderr, flush=True)
    
    # Cleanup intermediate _anon files (always active)
    try:
        if CANONICAL_OUTPUT and CANONICAL_OUTPUT.parent.exists():
            output_dir = CANONICAL_OUTPUT.parent
            anon_suffix = CANONICAL_OUTPUT.suffix or '.jpeg'
            anon_pattern = f"*_anon{anon_suffix}"
            anon_count = 0
            for anon_file in output_dir.glob(anon_pattern):
                try:
                    anon_file.unlink()
                    anon_count += 1
                except Exception as e:
                    print(f"[cleanup] Failed to delete {anon_file.name}: {e}", file=sys.stderr, flush=True)
            if anon_count > 0:
                print(f"[cleanup] Deleted {anon_count} intermediate _anon file(s)", file=sys.stderr, flush=True)
    except Exception as e:
        print(f"[cleanup] Failed to cleanup _anon files: {e}", file=sys.stderr, flush=True)

    # Cleanup temporary audit_masked images from companion folder
    try:
        if companion_folder and companion_folder.exists():
            audit_pattern = "*_audit_masked.png"
            audit_count = 0
            for audit_file in companion_folder.glob(audit_pattern):
                try:
                    audit_file.unlink()
                    audit_count += 1
                except Exception as e:
                    print(f"[cleanup] Failed to delete {audit_file.name}: {e}", file=sys.stderr, flush=True)
            if audit_count > 0:
                print(f"[cleanup] Deleted {audit_count} temporary audit_masked image(s)", file=sys.stderr, flush=True)
    except Exception as e:
        print(f"[cleanup] Failed to cleanup audit_masked files: {e}", file=sys.stderr, flush=True)
    
    if False:  # Disabled cleanup block for other files
        if CANONICAL_OUTPUT and CANONICAL_OUTPUT.parent.exists():
            output_dir = CANONICAL_OUTPUT.parent
            kept_files = {CANONICAL_OUTPUT.name, CANONICAL_OUTPUT.with_suffix('.log').name}
            
        try:
            # Delete JSON file
            json_path = CANONICAL_OUTPUT.with_suffix('.json')
            if json_path.exists():
                json_path.unlink()
                print(f"[cleanup] Deleted JSON: {json_path.name}", file=sys.stderr, flush=True)
        except Exception as e:
            print(f"[cleanup] Failed to delete JSON: {e}", file=sys.stderr, flush=True)
        
        try:
            # Delete all mask files (*_mask.png)
            mask_count = 0
            for mask_file in output_dir.glob('*_mask.png'):
                try:
                    mask_file.unlink()
                    mask_count += 1
                except Exception as e:
                    print(f"[cleanup] Failed to delete mask {mask_file.name}: {e}", file=sys.stderr, flush=True)
            if mask_count > 0:
                print(f"[cleanup] Deleted {mask_count} mask file(s)", file=sys.stderr, flush=True)
        except Exception as e:
            print(f"[cleanup] Failed to delete masks: {e}", file=sys.stderr, flush=True)
        
        # (_anon file cleanup now handled outside disabled block)
        
        try:
            # Delete tmp directory if it exists
            tmp_dir = output_dir / "tmp"
            if tmp_dir.exists() and tmp_dir.is_dir():
                shutil.rmtree(tmp_dir)
                print(f"[cleanup] Deleted tmp directory: {tmp_dir}", file=sys.stderr, flush=True)
        except Exception as e:
            print(f"[cleanup] Failed to delete tmp directory: {e}", file=sys.stderr, flush=True)
        
        # NOTE: Keep masks subdirectory - contains individual PII masks and combined PII mask
        # try:
        #     # Delete masks subdirectory if it exists
        #     masks_dir = output_dir / "masks"
        #     if masks_dir.exists() and masks_dir.is_dir():
        #         shutil.rmtree(masks_dir)
        #         print(f"[cleanup] Deleted masks directory: {masks_dir}", file=sys.stderr, flush=True)
        # except Exception as e:
        #     print(f"[cleanup] Failed to delete masks directory: {e}", file=sys.stderr, flush=True)
    
    # DISABLED FOR DEBUGGING: Also cleanup global artifacts/tmp directory (created by detector server)
    if False:  # Disabled - keep tmp files for debugging
        try:
            global_tmp_dir = Path("artifacts") / "tmp"
            if global_tmp_dir.exists():
                shutil.rmtree(global_tmp_dir)
                print(f"[cleanup] Deleted global tmp directory: {global_tmp_dir}", file=sys.stderr, flush=True)
        except Exception as e:
            print(f"[cleanup] Failed to delete global tmp directory: {e}", file=sys.stderr, flush=True)
    
    print(f"[cleanup] Final output: {CANONICAL_OUTPUT}", file=sys.stderr, flush=True)
    print(f"[cleanup] Companion folder: {companion_folder}", file=sys.stderr, flush=True)
    
    # Final summary of saved files
    print(f"\n{'='*80}", file=sys.stderr, flush=True)
    print(f"[Pipeline] ANONYMIZATION COMPLETE", file=sys.stderr, flush=True)
    print(f"{'='*80}", file=sys.stderr, flush=True)
    print(f"📄 Anonymized image: {CANONICAL_OUTPUT}", file=sys.stderr, flush=True)
    print(f"📁 Companion folder: {companion_folder}/", file=sys.stderr, flush=True)
    print(f"   ├── 📊 {input_stem}.json (processing report)", file=sys.stderr, flush=True)
    print(f"   ├── 📋 {input_stem}.log (detailed log)", file=sys.stderr, flush=True)
    masks_dir = companion_folder / "masks"
    if masks_dir.exists():
        mask_count = len(list(masks_dir.glob('*.png')))
        print(f"   └── 🎭 masks/ ({mask_count} mask file(s))", file=sys.stderr, flush=True)
    print(f"{'='*80}\n", file=sys.stderr, flush=True)
    
    # Clean up terminal logging
    _cleanup_terminal_logging()
    
    return 0


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
