"""
core/ref_resolver.py — Unified reference image resolution and validation.

Single source of truth for ALL ref resolution across the Recoil engine.
Both the workspace MCP server and the CLI pipeline import from here.

v2 layout (post project-paths-refactor 2026-05-26):
Filesystem is the canonical state. Refs live at:
    projects/{slug}/assets/{kind}/{subject}/{subject}_{type}_{variant}_v{NN}.{ext}

Classes: char, loc, prop.

Resolution is direct: paths.asset_subject_dir(cls, subject) + iterdir filter.
The v1 cascade (with its canonical/legacy subtree fallback) was deleted —
there is now ONE physical location for refs.
"""

import logging
import re
import hashlib
import json
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional

from recoil.core.exceptions import RefDimensionUnknownError
from recoil.core.paths import ProjectPaths, VALID_ASSET_CLASSES
from recoil.core.ref_stem import ref_filename, subject_id_norm
from recoil.core.ref_types import RefAsset, ReferenceBundle, RefRole
from recoil.core.ref_errors import SheetIntegrityError

logger = logging.getLogger(__name__)

# ── Constants ──────────────────────────────────────────────────────

IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp")

TURNAROUND_ANGLES = ("front", "profile", "three_quarter", "back", "closeup")

# Per-shot ref requirements (number of characters in shot → required ref keys)
REFS_REQUIRED_BY_CHAR_COUNT = {
    1: ["hero", "front", "profile", "three_quarter"],
    2: ["hero", "front"],
}
DEFAULT_REQUIRED = ["hero"]

MIN_HERO_DIMENSION = 768

# Public mapping from legacy plural-English type → v3 class name.
# Kept ONLY for the workspace API surface that still receives "characters"/etc.
# from the frontend during the transition. Production code should use
# the v3 class name directly.
LEGACY_PLURAL_TO_CLASS = {
    "characters": "char",
    "locations": "loc",
    "props": "prop",
}


# ── Slugify (self-contained, no external imports) ─────────────────

_SLUGIFY_RE = re.compile(r'[^a-z0-9_]')


def slugify_asset_id(asset_id: str) -> str:
    """Universal slugify for characters, locations, and props.

    Preserves INT/EXT prefix (semantically meaningful for lighting).
    """
    slug = asset_id.lower()
    for prefix in ("int/ext. ", "int. ", "ext. "):
        if slug.startswith(prefix):
            replacement = prefix.replace("/", "_").replace(". ", "_").replace(".", "_")
            slug = replacement + slug[len(prefix):]
            break
    slug = slug.replace("/", "_")
    slug = slug.replace("'", "")
    slug = _SLUGIFY_RE.sub("_", slug)
    while "__" in slug:
        slug = slug.replace("__", "_")
    return slug.strip("_")


# ── Data Classes ──────────────────────────────────────────────────

class Severity(Enum):
    ERROR = "error"
    WARN = "warn"


@dataclass
class ValidationIssue:
    severity: Severity
    entity_type: str
    entity_id: str
    check: str
    message: str


class RefValidationError(Exception):
    """Raised when blocking ref issues prevent generation."""
    def __init__(self, issues: list):
        self.issues = issues
        messages = [
            f"  {i.severity.value.upper()} {i.entity_type}/{i.entity_id}: {i.message}"
            for i in issues
        ]
        super().__init__("Ref validation failed:\n" + "\n".join(messages))


class MissingRefsError(Exception):
    """Raised when refs are absent for an element that should have them.

    Replaces the v1-era MissingCanonicalRefsError. The v2 layout has no
    'canonical' concept — refs are either present at assets/{kind}/{subject}/
    or they are missing.
    """
    def __init__(self, element_id: str, element_type: str, project: str):
        self.element_id = element_id
        self.element_type = element_type
        self.project = project
        super().__init__(
            f"No refs for {element_type}/{element_id} in project {project!r}. "
            f"Expected at least one 'hero' variant under "
            f"assets/{element_type}/<slug>/."
        )


# Backwards-compat alias for one-cycle deprecation. Callers will migrate
# to MissingRefsError; remove the alias in the follow-up sweep.
MissingCanonicalRefsError = MissingRefsError


# ── In-Memory Dimension Cache ─────────────────────────────────────

_dim_cache: dict = {}
_hash_cache: dict = {}


def get_dimensions(path: Path):
    """Read image dimensions with mtime-based caching. Returns (w, h) or None."""
    try:
        mtime = path.stat().st_mtime
    except OSError:
        return None

    cache_key = str(path)
    cached = _dim_cache.get(cache_key)
    if cached and cached[0] == mtime:
        return cached[1]

    try:
        from PIL import Image, UnidentifiedImageError
        with Image.open(path) as img:
            dims = img.size
        _dim_cache[cache_key] = (mtime, dims)
        return dims
    except (OSError, UnidentifiedImageError) as e:
        logger.warning(
            "ref_resolver: dimension probe failed for %s (%s) — raising",
            path, e.__class__.__name__,
        )
        raise RefDimensionUnknownError(str(path), message=str(e)) from e


_get_dimensions = get_dimensions  # backwards-compat alias


def clear_dimension_cache():
    _dim_cache.clear()


def _content_hash(path: Path) -> Optional[str]:
    """Return sha256 with an mtime+size cache."""
    try:
        stat = path.stat()
    except OSError:
        return None
    cache_key = str(path)
    marker = (stat.st_mtime_ns, stat.st_size)
    cached = _hash_cache.get(cache_key)
    if cached and cached[0] == marker:
        return cached[1]
    h = hashlib.sha256()
    try:
        with path.open("rb") as f:
            for chunk in iter(lambda: f.read(65536), b""):
                h.update(chunk)
    except OSError:
        return None
    digest = h.hexdigest()
    _hash_cache[cache_key] = (marker, digest)
    return digest


# ── Filename parsing (light wrapper around taxonomy.py) ───────────

def _parse_filename(name: str) -> Optional[dict]:
    """Parse a v2-conformant filename. Returns dict or None if non-conformant.

    Conformant: {subject}_{type}_{variant}_v{NN}.{ext}
    """
    from recoil.pipeline._lib.taxonomy import parse_asset_filename, AssetNameError
    try:
        aid = parse_asset_filename(name)
        return {
            "subject": aid.subject,
            "type": aid.type,
            "variant": aid.variant,
            "version": aid.version,
            "ext": aid.ext,
        }
    except AssetNameError:
        return None


def _normalize_kind(entity_type: str) -> str:
    """Accept either v3 class name, v2 singular kind, or v1 legacy plural — return v3 class."""
    if entity_type in ("char", "loc", "prop"):
        return entity_type
    if entity_type == "identity":
        return "char"
    if entity_type in LEGACY_PLURAL_TO_CLASS:
        return LEGACY_PLURAL_TO_CLASS[entity_type]
    return entity_type


# ── Unified Resolution ────────────────────────────────────────────

def _resolve_legacy_flat_refs(
    paths_or_root,
    entity_type: str,
    entity_id: str,
    phase: Optional[str] = None,
    ref_kind: Optional[str] = None,
) -> dict:
    kind = _normalize_kind(entity_type)

    if isinstance(paths_or_root, ProjectPaths):
        paths = paths_or_root
    elif isinstance(paths_or_root, Path):
        # Heuristic: if the path's last component is "refs", the caller is using
        # the legacy v1 API. Log and return empty dict — they need to migrate.
        if paths_or_root.name == "refs":
            logger.warning(
                "ref_resolver: v1 refs_root API is dead. Pass a ProjectPaths instead. "
                "Received: %s", paths_or_root,
            )
            return {}
        paths = ProjectPaths.from_root(paths_or_root)
    else:
        raise TypeError(
            f"paths_or_root must be ProjectPaths or Path, got {type(paths_or_root)}"
        )

    slug = slugify_asset_id(entity_id)
    subject_dir = paths.asset_subject_dir(kind, slug)
    if not subject_dir.is_dir():
        return {}

    refs: dict = {}
    # Pass 1: walk conformant files in subject dir
    for p in sorted(subject_dir.iterdir()):
        if not p.is_file() or p.suffix.lower() not in IMAGE_EXTS:
            continue
        parsed = _parse_filename(p.name)
        if parsed is None:
            continue
        if ref_kind is not None and parsed["type"] != ref_kind:
            continue
        # Filename grammar uses '_' as its field delimiter, so conformant
        # filenames carry the subject in HYPHENATED form; directory slugs
        # keep underscores. Compare in hyphen space or multi-word ids
        # (int_lower_decks_corridor) can never resolve.
        if parsed["subject"] != slug.replace("_", "-"):
            continue
        if phase and not parsed["variant"].endswith(f"-{phase}"):
            # Phase-specific lookup: variant should end with -ph2 etc.
            continue
        if not phase and "-ph" in parsed["variant"]:
            # No phase requested → skip phase-specific variants
            continue
        # Map variant → ref key
        variant = parsed["variant"]
        # Strip phase suffix from the key
        if phase:
            key = variant[: -(len(phase) + 1)]
        else:
            key = variant
        # Latest version wins
        if key in refs:
            existing = _parse_filename(refs[key].name)
            if existing and parsed["version"] <= existing["version"]:
                continue
        refs[key] = p

    return refs


def _shelf_asset(
    paths: ProjectPaths,
    cls: str,
    subject_norm: str,
    kind: str,
    role: RefRole,
) -> Optional[RefAsset]:
    look_dir = paths.asset_look_dir(cls, subject_norm)
    if not look_dir.is_dir():
        return None
    for ext in IMAGE_EXTS:
        candidate = look_dir / ref_filename(subject_norm, kind, ext)
        if candidate.is_file():
            return RefAsset(
                path=candidate,
                role=role,
                subject=subject_norm,
                kind=kind,
                is_hero=True,
                content_hash=_content_hash(candidate),
                source="shelf",
            )
    return None


TURN_VIEWS = ("front", "profile", "back", "threequarter")


def _shelf_turn_views(
    paths: ProjectPaths,
    cls: str,
    subject_norm: str,
) -> list[RefAsset]:
    turn_dir = paths.asset_look_dir(cls, subject_norm) / "turn"
    if not turn_dir.is_dir():
        return []
    assets: list[RefAsset] = []
    for view in TURN_VIEWS:
        for ext in IMAGE_EXTS:
            candidate = turn_dir / ref_filename(subject_norm, "turn", ext, view=view)
            if candidate.is_file():
                assets.append(RefAsset(
                    path=candidate,
                    role="identity",
                    subject=subject_norm,
                    kind="turn",
                    is_hero=False,
                    content_hash=_content_hash(candidate),
                    source="shelf",
                    view=view,
                ))
                break
    return assets


def _shelf_fullbody(
    paths: ProjectPaths,
    cls: str,
    subject_norm: str,
) -> Optional[RefAsset]:
    look_dir = paths.asset_look_dir(cls, subject_norm)
    if not look_dir.is_dir():
        return None
    for ext in IMAGE_EXTS:
        candidate = look_dir / ref_filename(subject_norm, "fullbody", ext)
        if candidate.is_file():
            return RefAsset(
                path=candidate,
                role="identity",
                subject=subject_norm,
                kind="fullbody",
                is_hero=False,
                content_hash=_content_hash(candidate),
                source="shelf",
                view=None,
            )
    return None


def resolve_character_bundle(
    paths: "ProjectPaths",
    subject: str,
    *,
    phase: Optional[str] = None,
    max_turn_views: int = 3,
) -> "ReferenceBundle":
    identity_bundle = resolve_reference_bundle(
        paths, "char", subject, "identity", phase=phase
    )
    hero = next((a for a in identity_bundle.assets if a.is_hero), None)
    if hero is not None:
        assets: list[RefAsset] = [hero]
    else:
        assets = list(identity_bundle.assets)

    subject_norm = subject_id_norm(subject)
    fullbody = _shelf_fullbody(paths, "char", subject_norm)
    if fullbody is not None:
        assets.append(fullbody)

    turn_limit = max(0, max_turn_views)
    assets.extend(_shelf_turn_views(paths, "char", subject_norm)[:turn_limit])

    return ReferenceBundle(tuple(assets[:6]))


def _sidecar_path(media_path: Path) -> Path:
    return media_path.parent / f"{media_path.name}.json"


def _pool_asset(
    paths: ProjectPaths,
    cls: str,
    subject_norm: str,
    kind: str,
    role: RefRole,
) -> Optional[RefAsset]:
    pool_dir = paths.pool_dir(cls, subject_norm, kind)
    if not pool_dir.is_dir():
        return None
    for candidate in sorted(pool_dir.iterdir()):
        if not candidate.is_file() or candidate.suffix.lower() not in IMAGE_EXTS:
            continue
        sidecar = _sidecar_path(candidate)
        if not sidecar.is_file():
            continue
        try:
            data = json.loads(sidecar.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        if not (data.get("status") == "canonical" and data.get("is_hero") is True):
            continue
        return RefAsset(
            path=candidate,
            role=role,
            subject=subject_norm,
            kind=kind,
            is_hero=True,
            content_hash=data.get("content_sha256") or _content_hash(candidate),
            source="pool",
        )
    return None


def resolve_reference_bundle(
    paths: "ProjectPaths",
    cls: str,
    subject: str,
    kind: str = "identity",
    *, phase: Optional[str] = None, role: "RefRole" = "identity",
) -> "ReferenceBundle":
    """The ONE resolver.

    Precedence is shelf(active) → pool(promoted-only) → legacy-flat. Empty
    bundles are legitimate; fail-closed decisions live in the Phase 4 gate.
    """
    cls_canonical = _normalize_kind(cls)
    if cls_canonical not in VALID_ASSET_CLASSES:
        raise ValueError(f"Invalid asset class: {cls!r}")
    subject_norm = subject_id_norm(subject)

    shelf = _shelf_asset(paths, cls_canonical, subject_norm, kind, role)
    if shelf is not None:
        return ReferenceBundle((shelf,))

    pool = _pool_asset(paths, cls_canonical, subject_norm, kind, role)
    if pool is not None:
        return ReferenceBundle((pool,))

    legacy_refs = _resolve_legacy_flat_refs(
        paths, cls_canonical, subject_norm, phase=phase, ref_kind=kind
    )
    if phase is not None and "hero" not in legacy_refs:
        legacy_refs = _resolve_legacy_flat_refs(
            paths, cls_canonical, subject_norm, phase=None, ref_kind=kind
        )
    if not legacy_refs:
        return ReferenceBundle()

    logger.warning(
        "ref_resolver: using legacy-flat refs for %s/%s kind=%s phase=%s; "
        "migrate to shelf hero layout",
        cls_canonical, subject_norm, kind, phase,
    )
    assets = tuple(
        RefAsset(
            path=p,
            role=role,
            subject=subject_norm,
            kind=kind,
            is_hero=(key == "hero"),
            content_hash=_content_hash(p),
            source="legacy",
        )
        for key, p in legacy_refs.items()
    )
    return ReferenceBundle(assets)


_MIN_SHEET_BYTES = 100_000
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"


def _sheet_is_real(p: Path) -> tuple[bool, str]:
    """(ok, reason). A sheet is real iff it is >= _MIN_SHEET_BYTES, carries the PNG
    magic, and decodes to >= 512x512. Ported verbatim from the deleted
    dispatch_payload._sheet_is_real — rejects the 1x1 / zero-byte / truncated clobber
    class that an existence-only check silently lets through."""
    try:
        size = p.stat().st_size
    except OSError as e:
        return False, f"stat failed: {e}"
    if size < _MIN_SHEET_BYTES:
        return False, f"too small: {size}B < {_MIN_SHEET_BYTES}B"
    try:
        with open(p, "rb") as fh:
            if fh.read(8) != _PNG_MAGIC:
                return False, "bad PNG magic"
    except OSError as e:
        return False, f"read failed: {e}"
    try:
        from PIL import Image

        with Image.open(p) as im:
            w, h = im.size
    except Exception as e:  # noqa: BLE001 — any decode failure means "not a real sheet"
        return False, f"undecodable: {e}"
    if w < 512 or h < 512:
        return False, f"bad dims {w}x{h}"
    return True, ""


def resolve_sheet_asset(
    paths: "ProjectPaths", cls: str, subject: str
) -> Optional[RefAsset]:
    """The canonical composite-SHEET resolver (existence + validity).

    Looks the sheet up via ProjectPaths.sheet_path (the layout SSOT). ABSENT ->
    None (caller treats as fell_back). EXISTS-but-INVALID -> raises
    SheetIntegrityError (fail-LOUD before any spend, succeeding the deleted
    CompositeSheetError). VALID -> RefAsset(role="sheet", kind="sheet",
    source="sheet"). This is the canonical_target bundle-lineage sheet surface;
    consumers attach the returned asset to a ReferenceBundle. PUBLIC because it is
    a cross-package canonical surface consumed by dispatch_payload."""
    sp = paths.sheet_path(cls, subject)
    if not sp.exists():
        return None
    ok, reason = _sheet_is_real(sp)
    if not ok:
        raise SheetIntegrityError(
            f"composite sheet INVALID for {cls}/{subject} at {sp}: {reason}"
        )
    return RefAsset(
        path=sp,
        role="sheet",
        subject=subject_id_norm(subject),
        kind="sheet",
        source="sheet",
    )


def resolve_entity_refs(
    paths_or_root,
    entity_type: str,
    entity_id: str,
    phase: Optional[str] = None,
) -> dict:
    """Resolve refs for any entity type under v2 layout.

    Args:
        paths_or_root: Either a ProjectPaths instance, or a Path pointing at
            the project root (back-compat — wrapped into ProjectPaths internally).
            v1 callers that passed the deprecated refs-root path are NO LONGER
            SUPPORTED; they receive an empty dict (and a warning log).
        entity_type: "char", "loc", "prop" (preferred) OR the legacy
            "identity", "characters", "locations", "props" (auto-translated).
        entity_id: Entity identifier (e.g., "SADIE", "int_sadie_apartment").
        phase: Optional wardrobe phase override (e.g., "ph2").

    Returns:
        Dict mapping ref key -> absolute Path. Keys include:
        "hero", "front", "profile", "three_quarter", "back",
        and any additional angles found.
    """
    return _resolve_legacy_flat_refs(
        paths_or_root, entity_type, entity_id, phase=phase
    )


# ── Backwards-Compat Wrappers ─────────────────────────────────────

def resolve_character_refs(paths_or_root, char_id: str, phase=None) -> dict:
    return resolve_entity_refs(paths_or_root, "char", char_id, phase=phase)


def resolve_location_refs(paths_or_root, location_id: str) -> dict:
    return resolve_entity_refs(paths_or_root, "loc", location_id)


def resolve_prop_refs(paths_or_root, prop_id: str) -> dict:
    return resolve_entity_refs(paths_or_root, "prop", prop_id)


# ── Unified Element Entry Point ─────────────────────────────────────

_ALLOWED_ELEMENT_TYPES = ("char", "loc", "prop", "identity", "characters", "locations", "props")


def get_element_refs(
    element_id: str,
    project: str,
    element_type: str,
    phase: Optional[str] = None,
) -> dict:
    """Single canonical entry point for element ref resolution.

    Required `element_type` — auto-detection on slug collision is a
    silent-bug factory (ADR-0005).

    Returns the v2 ref dict. Raises MissingRefsError if no refs found.
    """
    if element_type not in _ALLOWED_ELEMENT_TYPES:
        raise ValueError(
            f"element_type must be one of {_ALLOWED_ELEMENT_TYPES}, got {element_type!r}"
        )
    kind = _normalize_kind(element_type)
    paths = ProjectPaths.for_project(project)
    refs = resolve_entity_refs(paths, kind, element_id, phase=phase)
    if not refs:
        raise MissingRefsError(element_id, kind, project)
    return refs


# ── Project-Wide Resolution ──────────────────────────────────────

def get_all_project_refs(paths: ProjectPaths) -> dict:
    """Scan all class directories and return the full ref package for a project.

    Returns:
        {
            "char": {"jade": {"hero": Path, "front": Path, ...}},
            "loc": {"int_sadie_apartment": {"hero": Path}},
            "prop": {"blaster": {"hero": Path}},
        }
    """
    result: dict = {}
    for cls in sorted(VALID_ASSET_CLASSES):
        result[cls] = {}
        class_dir = paths.asset_class_dir(cls)
        if not class_dir.is_dir():
            continue
        for subject_dir in sorted(class_dir.iterdir()):
            if not subject_dir.is_dir() or subject_dir.name.startswith("."):
                continue
            refs = resolve_entity_refs(paths, cls, subject_dir.name)
            if refs:
                result[cls][subject_dir.name] = refs
    return result


def serialize_refs_for_workspace(refs_tree: dict, project_dir: Path) -> dict:
    """Convert Path objects to relative strings for the workspace MCP."""
    result: dict = {}
    for kind, entities in refs_tree.items():
        result[kind] = {}
        for entity_id, refs in entities.items():
            result[kind][entity_id] = {
                key: str(path.relative_to(project_dir))
                for key, path in refs.items()
            }
    return result


# ── Validation ───────────────────────────────────────────────────

def validate_entity_refs(paths: ProjectPaths, entity_type: str, entity_id: str) -> list:
    """Validate refs for a single entity. Checks hero existence and dimensions."""
    issues: list = []
    kind = _normalize_kind(entity_type)
    refs = resolve_entity_refs(paths, kind, entity_id)

    if "hero" not in refs:
        issues.append(ValidationIssue(
            Severity.ERROR, kind, entity_id, "hero_missing",
            "No hero ref found",
        ))
        return issues

    dims = _get_dimensions(refs["hero"])
    if dims and min(dims) < MIN_HERO_DIMENSION:
        issues.append(ValidationIssue(
            Severity.WARN, kind, entity_id, "hero_undersized",
            f"Hero is {dims[0]}x{dims[1]}, minimum {MIN_HERO_DIMENSION}px",
        ))

    return issues


def validate_refs_for_shot(
    paths: ProjectPaths,
    characters: list,
    location_id: Optional[str] = None,
    phase: Optional[str] = None,
) -> list:
    """Scene-aware validation for a specific shot."""
    issues: list = []
    num_chars = len(characters)
    required_types = REFS_REQUIRED_BY_CHAR_COUNT.get(num_chars, DEFAULT_REQUIRED)

    for char_id in characters:
        refs = resolve_entity_refs(paths, "char", char_id, phase=phase)

        if "hero" not in refs:
            issues.append(ValidationIssue(
                Severity.ERROR, "char", char_id, "hero_missing",
                "No hero ref found",
            ))
            continue

        dims = _get_dimensions(refs["hero"])
        if dims and min(dims) < MIN_HERO_DIMENSION:
            issues.append(ValidationIssue(
                Severity.ERROR, "char", char_id, "hero_undersized",
                f"Hero is {dims[0]}x{dims[1]}, minimum {MIN_HERO_DIMENSION}px",
            ))

        for ref_type in required_types:
            if ref_type == "hero":
                continue
            if ref_type not in refs:
                issues.append(ValidationIssue(
                    Severity.WARN if num_chars > 1 else Severity.ERROR,
                    "char", char_id, f"{ref_type}_missing",
                    f"Missing {ref_type} turnaround for {num_chars}-char shot",
                ))

    if location_id:
        loc_refs = resolve_entity_refs(paths, "loc", location_id)
        if "hero" not in loc_refs:
            issues.append(ValidationIssue(
                Severity.ERROR, "loc", location_id, "hero_missing",
                "No location ref found",
            ))

    return issues


def validate_all_project_refs(paths: ProjectPaths, refs_tree=None) -> list:
    """Validate all refs across the entire project."""
    issues: list = []
    if refs_tree is None:
        refs_tree = get_all_project_refs(paths)
    for kind, entities in refs_tree.items():
        for entity_id, refs in entities.items():
            if "hero" not in refs:
                issues.append(ValidationIssue(
                    Severity.ERROR, kind, entity_id, "hero_missing",
                    "No hero ref found",
                ))
                continue
            dims = _get_dimensions(refs["hero"])
            if dims and min(dims) < MIN_HERO_DIMENSION:
                issues.append(ValidationIssue(
                    Severity.WARN, kind, entity_id, "hero_undersized",
                    f"Hero is {dims[0]}x{dims[1]}, minimum {MIN_HERO_DIMENSION}px",
                ))
    return issues


def has_blocking_issues(issues: list) -> bool:
    return any(i.severity == Severity.ERROR for i in issues)
