"""Reusable helpers shared across CLI commands.

This module centralizes orchestration helpers that are needed by multiple CLI
commands, including classification cache lookups, descriptor normalization,
and aggregation of ingestion/classification analytics. The functions are
written in a framework-agnostic fashion so they can be reused by interactive
commands (`dorgy org`, `dorgy watch`) without re-implementing complex logic.
"""

from __future__ import annotations

from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any, Callable, Iterable, Optional

from dorgy.classification import (
    ClassificationBatch,
    ClassificationCache,
    ClassificationDecision,
    ClassificationEngine,
    ClassificationRequest,
)
from dorgy.config import DorgyConfig
from dorgy.ingestion import FileDescriptor
from dorgy.organization.models import OperationPlan
from dorgy.state import FileRecord


def decision_cache_key(descriptor: FileDescriptor, root: Path) -> Optional[str]:
    """Return a stable classification cache key for a descriptor.

    Args:
        descriptor: Descriptor generated by the ingestion pipeline.
        root: Root directory associated with the current collection.

    Returns:
        Optional[str]: Hash identifier if available, otherwise a relative path.
    """

    if descriptor.hash:
        return descriptor.hash
    try:
        return str(descriptor.path.relative_to(root))
    except ValueError:
        return str(descriptor.path)


def resolve_prompt_text(prompt: Optional[str], prompt_file: Optional[str]) -> Optional[str]:
    """Return the prompt text, preferring the contents of `prompt_file` when provided.

    Args:
        prompt: Prompt supplied directly via the CLI flag.
        prompt_file: Path to a file containing prompt instructions.

    Returns:
        Optional[str]: Prompt content ready for downstream components.

    Raises:
        OSError: If the prompt file cannot be read.
        UnicodeDecodeError: If the prompt file contains undecodable text.
    """

    if not prompt_file:
        return prompt
    path = Path(prompt_file).expanduser()
    return path.read_text(encoding="utf-8")


def run_classification(
    descriptors: Iterable[FileDescriptor],
    prompt: Optional[str],
    root: Path,
    dry_run: bool,
    config: DorgyConfig,
    cache: Optional[ClassificationCache],
    *,
    on_progress: Optional[
        Callable[[str, int, int, FileDescriptor, Optional[int], Optional[float]], None]
    ] = None,
    max_workers: int = 1,
) -> ClassificationBatch:
    """Execute the classification workflow with caching and fallbacks.

    Args:
        descriptors: Descriptors awaiting classification.
        prompt: Optional additional instruction for the classifier.
        root: Collection root used for relative cache keys.
        dry_run: Indicates whether the current run skips side effects.
        config: Loaded Dorgy configuration.
        cache: Optional classification cache instance.
        on_progress: Optional callback invoked for classification progress events.
            Receives the event type (``"start"`` or ``"complete"``), number of
            descriptors completed so far, total descriptors, the descriptor itself,
            the worker identifier (``None`` for cached results), and the elapsed
            duration in seconds when available.

    Returns:
        ClassificationBatch: Decisions paired with any encountered errors.
    """

    descriptors = list(descriptors)
    if not descriptors:
        return ClassificationBatch()

    cache_instance = cache
    if cache_instance is not None:
        cache_instance.load()

    decisions: list[Optional[ClassificationDecision]] = [None] * len(descriptors)
    errors: list[str] = []
    pending_requests: list[ClassificationRequest] = []
    pending_indices: list[int] = []
    pending_keys: list[Optional[str]] = []

    total = len(descriptors)
    progress_lock = Lock()
    completed_count = 0

    def notify(event: str, idx: int, worker_id: Optional[int], duration: Optional[float]) -> None:
        nonlocal completed_count
        if on_progress is None or idx >= total:
            return
        descriptor = descriptors[idx]
        with progress_lock:
            if event == "complete":
                completed_count += 1
                processed = completed_count
            else:
                processed = completed_count
        on_progress(event, processed, total, descriptor, worker_id, duration)

    for index, descriptor in enumerate(descriptors):
        key = decision_cache_key(descriptor, root)
        cached = cache_instance.get(key) if cache_instance is not None and key is not None else None
        if cached is not None:
            decisions[index] = cached
            notify("start", index, worker_id=None, duration=None)
            notify("complete", index, worker_id=None, duration=0.0)
            continue
        pending_indices.append(index)
        pending_keys.append(key)
        pending_requests.append(
            ClassificationRequest(
                descriptor=descriptor,
                prompt=prompt,
                collection_root=root,
            )
        )

    max_workers = max(1, max_workers)

    if pending_requests:
        engine = ClassificationEngine(config.llm)

        def _on_classification_progress(
            local_index: int,
            request: ClassificationRequest,
            worker_id: int,
            event: str,
            duration: float | None,
            error: Exception | None,
        ) -> None:
            original_index = pending_indices[local_index]
            notify(event, original_index, worker_id, duration)

        batch = engine.classify(
            pending_requests,
            max_workers=max_workers,
            progress_callback=_on_classification_progress if on_progress else None,
        )
        errors.extend(batch.errors)
        for idx, decision, key in zip(pending_indices, batch.decisions, pending_keys, strict=False):
            if decision is not None:
                decisions[idx] = decision
                if not dry_run and cache_instance is not None and key is not None:
                    cache_instance.set(key, decision)

    if cache_instance is not None and not dry_run:
        cache_instance.save()

    return ClassificationBatch(decisions=decisions, errors=errors)


def zip_decisions(
    batch: ClassificationBatch,
    descriptors: Iterable[FileDescriptor],
) -> Iterable[tuple[Optional[ClassificationDecision], FileDescriptor]]:
    """Yield pairs of classification decisions and their descriptors.

    Args:
        batch: Classification batch produced by :func:`run_classification`.
        descriptors: Descriptors from the ingestion pipeline.

    Yields:
        Tuples pairing decisions (or ``None``) with descriptors.
    """

    decisions = list(batch.decisions)
    descriptors = list(descriptors)
    for index, descriptor in enumerate(descriptors):
        decision = decisions[index] if index < len(decisions) else None
        yield decision, descriptor


def relative_to_collection(path: Path, root: Path) -> str:
    """Return ``path`` relative to ``root`` when possible.

    Args:
        path: Absolute candidate path.
        root: Collection root path.

    Returns:
        str: Relative string representation or absolute fallback.
    """

    try:
        return str(path.relative_to(root))
    except ValueError:
        return str(path)


def build_original_snapshot(
    descriptors: Iterable[FileDescriptor],
    root: Path,
) -> dict[str, Any]:
    """Create a snapshot of pre-organization descriptors.

    Args:
        descriptors: Descriptors slated for organization.
        root: Root directory used for relative path computation.

    Returns:
        dict[str, Any]: Snapshot payload suitable for persistence.
    """

    generated_at = datetime.now(timezone.utc).isoformat()
    entries: list[dict[str, Any]] = []
    for descriptor in descriptors:
        entries.append(
            {
                "path": relative_to_collection(descriptor.path, root),
                "display_name": descriptor.display_name,
                "mime_type": descriptor.mime_type,
                "hash": descriptor.hash,
                "size_bytes": descriptor.metadata.get("size_bytes"),
                "tags": list(descriptor.tags),
            }
        )

    return {"generated_at": generated_at, "entries": entries}


def descriptor_to_record(
    descriptor: FileDescriptor,
    decision: Optional[ClassificationDecision],
    root: Path,
) -> FileRecord:
    """Convert a descriptor and decision into a state record.

    Args:
        descriptor: Descriptor produced by ingestion.
        decision: Optional classification decision.
        root: Collection root for relative path computation.

    Returns:
        FileRecord: Serialized metadata for persistence.
    """

    try:
        relative = descriptor.path.relative_to(root)
    except ValueError:
        relative = descriptor.path

    last_modified = None
    modified_raw = descriptor.metadata.get("modified_at")
    if modified_raw:
        try:
            normalized = (
                modified_raw.replace("Z", "+00:00") if modified_raw.endswith("Z") else modified_raw
            )
            last_modified = datetime.fromisoformat(normalized)
        except ValueError:
            last_modified = None

    categories: list[str] = []
    tags: list[str] = descriptor.tags
    confidence: Optional[float] = None
    rename_suggestion: Optional[str] = None
    reasoning: Optional[str] = None
    needs_review = False
    vision_caption = descriptor.metadata.get("vision_caption")
    raw_labels = descriptor.metadata.get("vision_labels")
    if isinstance(raw_labels, list):
        vision_labels = [str(label).strip() for label in raw_labels if str(label).strip()]
    elif isinstance(raw_labels, str):
        vision_labels = [part.strip() for part in raw_labels.split(",") if part.strip()]
    else:
        vision_labels = []

    raw_confidence = descriptor.metadata.get("vision_confidence")
    vision_confidence: Optional[float] = None
    if isinstance(raw_confidence, (int, float)):
        vision_confidence = float(raw_confidence)
    elif isinstance(raw_confidence, str):
        try:
            vision_confidence = float(raw_confidence)
        except ValueError:
            vision_confidence = None

    vision_reasoning = descriptor.metadata.get("vision_reasoning")
    if isinstance(vision_reasoning, str) and not vision_reasoning.strip():
        vision_reasoning = None
    if vision_caption is not None and isinstance(vision_caption, str):
        vision_caption = vision_caption.strip() or None
    else:
        vision_caption = None

    if decision is not None:
        categories = [decision.primary_category]
        categories.extend(decision.secondary_categories)
        tags = decision.tags or categories
        confidence = decision.confidence
        rename_suggestion = decision.rename_suggestion
        reasoning = decision.reasoning
        needs_review = decision.needs_review

    return FileRecord(
        path=str(relative),
        hash=descriptor.hash,
        tags=tags,
        categories=categories,
        confidence=confidence,
        last_modified=last_modified,
        rename_suggestion=rename_suggestion,
        reasoning=reasoning,
        needs_review=needs_review,
        vision_caption=vision_caption,
        vision_labels=vision_labels,
        vision_confidence=vision_confidence,
        vision_reasoning=vision_reasoning,
    )


def compute_org_counts(
    result: Any,
    classification_batch: ClassificationBatch,
    plan: OperationPlan,
) -> dict[str, int]:
    """Return a summary of organization metrics for reporting.

    Args:
        result: Ingestion result carrying lists of processed paths and errors.
        classification_batch: Batch containing decisions and errors.
        plan: Operation plan summarizing rename/move/metadata actions.

    Returns:
        dict[str, int]: Mapping of metric names to counts.
    """

    ingestion_errors = len(result.errors)
    classification_errors = len(classification_batch.errors)
    conflict_count = sum(1 for operation in plan.renames if operation.conflict_applied)
    conflict_count += sum(1 for operation in plan.moves if operation.conflict_applied)

    return {
        "processed": len(result.processed),
        "needs_review": len(result.needs_review),
        "quarantined": len(result.quarantined),
        "renames": len(plan.renames),
        "moves": len(plan.moves),
        "metadata_updates": len(plan.metadata_updates),
        "deletes": len(plan.deletes),
        "conflicts": conflict_count,
        "ingestion_errors": ingestion_errors,
        "classification_errors": classification_errors,
        "errors": ingestion_errors + classification_errors,
    }


def collect_error_payload(
    result: Any,
    classification_batch: ClassificationBatch,
) -> dict[str, list[str]]:
    """Collect structured error payloads for ingestion and classification.

    Args:
        result: Ingestion result with error details.
        classification_batch: Classification batch with error details.

    Returns:
        dict[str, list[str]]: Mapping of error categories to lists of messages.
    """

    return {
        "ingestion": list(result.errors),
        "classification": list(classification_batch.errors),
    }


__all__ = [
    "build_original_snapshot",
    "collect_error_payload",
    "compute_org_counts",
    "decision_cache_key",
    "descriptor_to_record",
    "resolve_prompt_text",
    "relative_to_collection",
    "run_classification",
    "zip_decisions",
]
