# recoil/pipeline/_lib/asset_ops.py
"""Pure-Python asset mutation layer (Phase 1).

Wraps ref_resolver for reads. Writes files + sidecars for mutations. Logs
every mutation to state/visual/ops.log.jsonl via the two-line protocol.

This module is the ONLY thing that should write to assets/<class>/<subject>/ in the
new (v3) layout. The frozen review_server.py still has its own write
paths during the build window — those are scheduled for elimination in
Phase 1 step 3 (legacy-state-file removal).
"""

from __future__ import annotations

import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from recoil.core import ref_resolver
from recoil.core.paths import ProjectPaths
from recoil.pipeline._lib.ops_log import (
    log_op_started,
    log_op_completed,
    log_op_failed,
    make_op_id,
)
from recoil.pipeline._lib.sidecar import compute_sha256, write_sidecar


# v3 asset-class aliases for the legacy (character / location / prop)
# vocabulary still used by asset_ops's public API. ProjectPaths.asset_subject_dir
# is the SSOT for the assets/<class>/<subject>/ layout.
_TYPE_TO_CLASS = {
    "character": "char",
    "location": "loc",
    "prop": "prop",
}


def _canonical_dir(project_root: Path, asset_type: str, asset_id: str) -> Path:
    cls = _TYPE_TO_CLASS[asset_type]
    paths = ProjectPaths.from_root(project_root)
    return paths.asset_subject_dir(cls, asset_id)


def _ops_log_path(project_root: Path) -> Path:
    return ProjectPaths.from_root(project_root).visual_state_dir / "ops.log.jsonl"


def _hero_filename(phase: str | None, ext: str) -> str:
    if phase:
        return f"hero_{phase}{ext}"
    return f"hero{ext}"


def _ts() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _build_manifest(
    file_path: Path, op_id: str, op_name: str, context: dict, model: dict | None = None
) -> dict:
    return {
        "schema_version": 1,
        "file": {
            "path": str(file_path),
            "sha256": compute_sha256(file_path),
            "size_bytes": file_path.stat().st_size,
            "mtime": file_path.stat().st_mtime,
        },
        "op": {
            "id": op_id,
            "name": op_name,
            "operator": context.get("operator", "unknown"),
            "timestamp": _ts(),
        },
        "context": context,
        "model": model,
    }


def _remove_colliding_hero(
    canonical: Path, phase: str | None, source: Path | None = None
) -> None:
    """Remove any existing hero file (and its sidecar) for the same phase
    regardless of extension. This is the core extension-masking fix.

    Skips files that are the same as `source` (protects against a user
    promoting an existing canonical file in place)."""
    source_resolved: Path | None = None
    if source is not None:
        try:
            source_resolved = source.resolve()
        except OSError:
            source_resolved = None
    if phase is None:
        # Match hero.<ext> (no phase suffix) — exclude hero_<phase>.<ext>
        pattern = "hero.*"
    else:
        # Match hero_<phase>.<ext>
        pattern = f"hero_{phase}.*"
    for existing in canonical.glob(pattern):
        if not existing.is_file():
            continue
        if source_resolved is not None:
            try:
                if existing.resolve() == source_resolved:
                    continue
            except OSError:
                pass
        existing.unlink(missing_ok=True)
        sidecar = canonical / "_meta" / f"{existing.name}.json"
        sidecar.unlink(missing_ok=True)


def set_hero(
    *,
    project_root: Path,
    asset_type: str,
    asset_id: str,
    source: Path,
    phase: str | None = None,
    operator: str = "asset_ops",
) -> Path:
    """Promote a source file to canonical hero. Replaces any existing hero
    with the same phase regardless of extension."""
    op_id = make_op_id()
    log_path = _ops_log_path(project_root)
    canonical = _canonical_dir(project_root, asset_type, asset_id)
    canonical.mkdir(parents=True, exist_ok=True)

    log_op_started(
        log_path,
        op_id=op_id,
        name="asset.hero.set",
        args={
            "asset_type": asset_type,
            "asset_id": asset_id,
            "source": str(source),
            "phase": phase,
        },
        context={"operator": operator},
    )

    try:
        _remove_colliding_hero(canonical, phase, source=source)

        ext = source.suffix.lower()
        target = canonical / _hero_filename(phase, ext)

        # In-place promotion: source is already at target (user "re-sets" the same file).
        # Skip the copy but still rewrite the sidecar manifest to refresh the op metadata.
        if source.resolve() == target.resolve():
            pass
        else:
            shutil.copy2(source, target)

        manifest = _build_manifest(
            target,
            op_id,
            "asset.hero.set",
            {
                "project": project_root.name,
                "asset_type": asset_type,
                "asset_id": asset_id,
                "phase": phase,
            },
        )
        write_sidecar(target, manifest)

        log_op_completed(log_path, op_id=op_id, outputs={"path": str(target)}, cost=0.0)
        return target
    except Exception as e:
        log_op_failed(log_path, op_id=op_id, error=str(e))
        raise


def add_ref(
    *,
    project_root: Path,
    asset_type: str,
    asset_id: str,
    source: Path,
    operator: str = "asset_ops",
) -> Path:
    op_id = make_op_id()
    log_path = _ops_log_path(project_root)
    canonical = _canonical_dir(project_root, asset_type, asset_id)
    canonical.mkdir(parents=True, exist_ok=True)

    log_op_started(
        log_path,
        op_id=op_id,
        name="asset.ref.add",
        args={"asset_type": asset_type, "asset_id": asset_id, "source": str(source)},
        context={"operator": operator},
    )

    try:
        target = canonical / source.name
        # Never mask a hero even accidentally
        if target.name.startswith("hero"):
            target = canonical / f"ref_{source.name}"

        # In-place: source already IS at target — skip copy but still write sidecar
        if source.resolve() != target.resolve():
            shutil.copy2(source, target)

        manifest = _build_manifest(
            target,
            op_id,
            "asset.ref.add",
            {
                "project": project_root.name,
                "asset_type": asset_type,
                "asset_id": asset_id,
            },
        )
        write_sidecar(target, manifest)

        log_op_completed(log_path, op_id=op_id, outputs={"path": str(target)}, cost=0.0)
        return target
    except Exception as e:
        log_op_failed(log_path, op_id=op_id, error=str(e))
        raise


def delete_ref(
    *,
    project_root: Path,
    asset_type: str,
    asset_id: str,
    filename: str,
    operator: str = "asset_ops",
) -> None:
    op_id = make_op_id()
    log_path = _ops_log_path(project_root)
    canonical = _canonical_dir(project_root, asset_type, asset_id)

    log_op_started(
        log_path,
        op_id=op_id,
        name="asset.ref.delete",
        args={"asset_type": asset_type, "asset_id": asset_id, "filename": filename},
        context={"operator": operator},
    )

    try:
        target = canonical / filename
        if target.exists():
            target.unlink()
        sidecar = canonical / "_meta" / f"{filename}.json"
        if sidecar.exists():
            sidecar.unlink()
        log_op_completed(log_path, op_id=op_id, outputs={"deleted": filename}, cost=0.0)
    except Exception as e:
        log_op_failed(log_path, op_id=op_id, error=str(e))
        raise


def list_refs(
    *,
    project_root: Path,
    asset_type: str,
    asset_id: str,
) -> Any:
    """Read-only — delegates to ref_resolver. Returns whatever shape
    ref_resolver chooses to return (dict or list)."""
    paths = ProjectPaths.from_root(project_root)
    if asset_type == "character":
        return ref_resolver.resolve_character_refs(paths, asset_id)
    if asset_type == "location":
        return ref_resolver.resolve_location_refs(paths, asset_id)
    if asset_type == "prop":
        return ref_resolver.resolve_prop_refs(paths, asset_id)
    raise ValueError(f"unknown asset_type: {asset_type}")


def get_hero(
    *,
    project_root: Path,
    asset_type: str,
    asset_id: str,
    phase: str | None = None,
) -> Path | None:
    """Return the hero ref path for an asset.

    For characters, phase is plumbed through to ref_resolver which returns
    the phased hero (if any) under the "hero" key. For locations and props,
    the underlying resolvers do not currently support phases — phase is
    honored only if the resolver happens to emit a hero_{phase} key.
    """
    paths = ProjectPaths.from_root(project_root)
    if asset_type == "character":
        refs = ref_resolver.resolve_character_refs(paths, asset_id, phase=phase)
    elif asset_type == "location":
        refs = ref_resolver.resolve_location_refs(paths, asset_id)
    elif asset_type == "prop":
        refs = ref_resolver.resolve_prop_refs(paths, asset_id)
    else:
        raise ValueError(f"unknown asset_type: {asset_type}")

    if isinstance(refs, dict):
        # Defensive: prefer an explicit phase-specific key if the resolver emits one
        if phase and f"hero_{phase}" in refs:
            return refs[f"hero_{phase}"]
        return refs.get("hero")
    # List return fallback (in case a resolver returns a list shape)
    if isinstance(refs, (list, tuple)):
        target_name = f"hero_{phase}" if phase else "hero"
        for r in refs:
            name = getattr(r, "name", str(r))
            if Path(name).stem == target_name:
                return r if isinstance(r, Path) else Path(r)
    return None


def tag_phase(
    *,
    project_root: Path,
    asset_type: str,
    asset_id: str,
    filename: str,
    phase: str,
    operator: str = "asset_ops",
) -> Path:
    """Rename a ref to include a phase suffix without changing its content."""
    op_id = make_op_id()
    log_path = _ops_log_path(project_root)
    canonical = _canonical_dir(project_root, asset_type, asset_id)

    log_op_started(
        log_path,
        op_id=op_id,
        name="asset.tag_phase",
        args={
            "asset_type": asset_type,
            "asset_id": asset_id,
            "filename": filename,
            "phase": phase,
        },
        context={"operator": operator},
    )

    try:
        src = canonical / filename
        if not src.exists():
            raise FileNotFoundError(f"ref not found: {src}")

        stem = src.stem
        ext = src.suffix
        target = canonical / f"{stem}_{phase}{ext}"

        # M-C: fail fast on target collision instead of silently overwriting
        if target.exists():
            raise FileExistsError(f"tag_phase target already exists: {target}")

        # M-B: parse old sidecar BEFORE filesystem mutation so a bad parse
        # doesn't leave a renamed file without a valid sidecar
        old_sidecar = canonical / "_meta" / f"{filename}.json"
        old_manifest = None
        if old_sidecar.exists():
            import json as _json

            try:
                old_manifest = _json.loads(old_sidecar.read_text())
            except (ValueError, OSError):
                old_manifest = None  # degrade to fresh manifest on parse failure

        src.rename(target)

        if old_manifest is not None:
            old_manifest.setdefault("file", {})
            old_manifest["file"]["path"] = str(target)
            old_manifest["op"] = {
                "id": op_id,
                "name": "asset.tag_phase",
                "operator": operator,
                "timestamp": _ts(),
            }
            write_sidecar(target, old_manifest)
            old_sidecar.unlink(missing_ok=True)
        else:
            # Old sidecar missing or unparseable — write a fresh one
            manifest = _build_manifest(
                target,
                op_id,
                "asset.tag_phase",
                {
                    "project": project_root.name,
                    "asset_type": asset_type,
                    "asset_id": asset_id,
                    "phase": phase,
                },
            )
            write_sidecar(target, manifest)

        log_op_completed(
            log_path, op_id=op_id, outputs={"renamed_to": str(target)}, cost=0.0
        )
        return target
    except Exception as e:
        log_op_failed(log_path, op_id=op_id, error=str(e))
        raise


def verify(
    *,
    project_root: Path,
    asset_type: str,
    asset_id: str,
) -> dict[str, Any]:
    """Walk all refs for an asset, verify each sidecar matches the file."""
    from recoil.pipeline._lib.sidecar import read_sidecar

    canonical = _canonical_dir(project_root, asset_type, asset_id)
    results: dict[str, Any] = {"checked": 0, "drifted": [], "missing_sidecar": []}
    for f in canonical.glob("*"):
        if f.is_dir() or f.name.startswith("_"):
            continue
        results["checked"] += 1
        rec = read_sidecar(f, force=True)
        if rec is None:
            results["missing_sidecar"].append(f.name)
        elif rec.drifted:
            results["drifted"].append(f.name)
    return results
