# ==============================================================================
# PROJECT PATHS — SSOT for v3 per-project layout
# DATE: 2026-05-30
# REPLACES: project_refs_dir() + project_output_dir() free functions
# ==============================================================================
"""
paths.py — Single source of truth for engine and project paths.

v3 layout (post layout-v3 + ref-taxonomy migration):

    projects/{slug}/
    ├── assets/{char,loc,prop}/{subject}/{look}/
    │   ├── {subject}_{kind}.{ext}          ← hero ref
    │   └── pool/{kind}/                    ← pool refs
    │       └── 4k/                         ← upscaled pool refs
    ├── prep/ep_{NNN}/
    ├── renders/ep_{NNN}/
    ├── scripting/{bible,episodes,development,compiled}/
    ├── _pipeline/{state,shot_plans,annotations,sessions,audio,tests,archive,visual}/
    ├── _history/{snapshots,archives,migration,debug}/
    └── project_config.json

v2 layout (deprecated, migrated by scripts/migrate_v3_layout.py):

    projects/{slug}/
    ├── assets/{identity,turn,expr,loc,prop,scene}/{subject}/
    ├── sequences/ep_{NNN}/
    ├── renders/ep_{NNN}/
    ├── state/{visual,manifests,bundles}/
    ├── bible/
    ├── episodes/
    ├── development/
    ├── _history/{snapshots,archives,migration,debug}/
    └── project_config.json

The legacy v1 layout (output/{refs,frames,previs,video,bundles,manifests}/)
is DESTROYED by scripts/migrate_v2_layout.py. No v1 fallback paths exist
in the codebase.
"""

import json
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

import re
import logging

from recoil.core.ref_stem import ref_filename, subject_stem

_LOG = logging.getLogger(__name__)


# ── Engine-level roots (unchanged from pre-v2) ────────────────────

RECOIL_ROOT = Path(__file__).resolve().parent.parent
CONFIG_DIR = RECOIL_ROOT / "config"
CONFIG_PATH = CONFIG_DIR / "pipeline_config.json"


class ProjectsRootUnresolvable(RuntimeError):
    """Raised when neither RECOIL_PROJECTS_ROOT nor pipeline_config.json
    provides a usable projects root directory. Law 4: fail loudly."""


class LayoutFrozenError(RuntimeError):
    """Raised when v2-only path properties are accessed on a project with
    layout_freeze == 'ad-hoc'. The project predates v2 layout and must be
    finished against its pre-migration git commit."""


class DeprecatedPathAPIError(RuntimeError):
    """Raised by the deprecation shims for project_refs_dir() and
    project_output_dir(). Migrate callers to ProjectPaths.

    See: consultations/recoil/project-paths-refactor-v2-2026-05-26/SYNTHESIS.md
    """


def _load_pipeline_config() -> dict:
    if not CONFIG_PATH.exists():
        return {}
    from .config_schema import validate_and_load
    return validate_and_load(CONFIG_PATH, "pipeline_config")


def _assert_data_root(root: Path) -> Path:
    sentinel = root / ".recoil-data-root"
    if sentinel.exists():
        return root
    # Sentinel missing. Distinguish a genuine Dropbox-paused / stub root (halt)
    # from a Smart-Sync eviction of the 0-byte marker while the data is still
    # present (self-heal). If the root holds real project subdirs, recreate the
    # sentinel — WITH content, so Smart-Sync is far less likely to evict it
    # again — and proceed. Otherwise halt (Law 4). (REC-101)
    try:
        has_data = root.is_dir() and any(
            (p / "project_config.json").exists()
            for p in root.iterdir()
            if p.is_dir() and not p.name.startswith(".")
        )
    except OSError:
        has_data = False
    if has_data:
        created = False
        try:
            sentinel.write_text("recoil-data-root\n")
            created = True
        except OSError:
            pass
        _LOG.warning(
            "_assert_data_root: .recoil-data-root sentinel was missing but %s "
            "holds real project data (a project_config.json is present) — %s "
            "(likely Dropbox Smart-Sync eviction of the 0-byte marker). (REC-101)",
            root,
            "recreated the sentinel" if created
            else "could NOT recreate it (read-only?); proceeding anyway",
        )
        return root
    raise ProjectsRootUnresolvable(
        f"{root} resolved but .recoil-data-root sentinel missing and no project "
        f"data present — Dropbox paused or Smart-Sync stub. Halting (Law 4)."
    )


def projects_root() -> Path:
    """Return the canonical projects root directory.

    Resolution order:
      1. RECOIL_PROJECTS_ROOT env var
      2. pipeline_config.json["projects_root"]
      3. RAISE ProjectsRootUnresolvable
    """
    override = os.environ.get("RECOIL_PROJECTS_ROOT", "").strip()
    if override:
        root = Path(override).expanduser().resolve()
        if not root.exists() or not root.is_dir():
            raise ProjectsRootUnresolvable(
                f"RECOIL_PROJECTS_ROOT={override!r} does not resolve to a directory."
            )
        return _assert_data_root(root)
    raw = get_pipeline_config().get("projects_root")
    if raw:
        root = Path(raw).expanduser().resolve()
        if not root.exists() or not root.is_dir():
            raise ProjectsRootUnresolvable(
                f"pipeline_config.json projects_root={raw!r} does not resolve."
            )
        return _assert_data_root(root)
    raise ProjectsRootUnresolvable(
        "Cannot resolve projects root: RECOIL_PROJECTS_ROOT not set and "
        "pipeline_config.json has no 'projects_root' key."
    )


_pipeline_config = _load_pipeline_config()

PIPELINE_ROOT = RECOIL_ROOT / "pipeline"
DEFAULT_PROJECT = _pipeline_config.get("default_project", "leviathan")
STATE_NAMESPACE = _pipeline_config.get("visual_state_namespace", "visual")


def ensure_pipeline_importable():
    """Add RECOIL_ROOT.parent, RECOIL_ROOT, and PIPELINE_ROOT to sys.path."""
    parent_str = str(RECOIL_ROOT.parent)
    recoil_str = str(RECOIL_ROOT)
    pipeline_str = str(PIPELINE_ROOT)
    if (len(sys.path) >= 3
        and sys.path[0] == parent_str
        and sys.path[1] == recoil_str
        and sys.path[2] == pipeline_str):
        return
    sys.path[:] = [p for p in sys.path if p not in (parent_str, pipeline_str, recoil_str)]
    sys.path.insert(0, pipeline_str)
    sys.path.insert(0, recoil_str)
    sys.path.insert(0, parent_str)


# ── ProjectPaths — the v2/v3 SSOT ───────────────────────────────────

VALID_ASSET_KINDS = frozenset({"identity", "turn", "expr", "loc", "prop", "scene"})

V3_DIRECTORIES = [
    "assets/char", "assets/loc", "assets/prop",
    "prep", "renders",
    "scripting/bible", "scripting/episodes", "scripting/development", "scripting/compiled",
    "_pipeline/state", "_pipeline/shot_plans", "_pipeline/annotations",
    "_pipeline/sessions", "_pipeline/audio", "_pipeline/tests",
    "_pipeline/archive", "_pipeline/visual",
    "_history/snapshots", "_history/archives", "_history/migration", "_history/debug",
]

VALID_ASSET_CLASSES = frozenset({"char", "loc", "prop"})
VALID_REF_TYPES = frozenset({"identity", "turn", "closeup", "fullbody", "expr"})

# ── Resolver constants ──────────────────────────────────────────────

# Class aliases that the resolver collapses to canonical names.
# These are removed in Phase 4 (post-1-week soak) after the on-disk migration completes.
_CLASS_ALIASES = {
    "identity": "char",   # tartarus legacy
    "character": "char",  # any straggler from v1
    "characters": "char",
    "location": "loc",
    "locations": "loc",
    "prop": "prop",
    "props": "prop",
}

# Magic-byte prefixes for image file headers. Tuple of (header_bytes, name).
_IMAGE_HEADERS = (
    (b"\x89PNG\r\n\x1a\n", "png"),
    (b"\xff\xd8\xff",      "jpeg"),
    (b"RIFF",              "webp_or_riff"),
    (b"GIF87a",            "gif"),
    (b"GIF89a",            "gif"),
)

# Minimum bytes for a real image. Broken LFS pointers are ~99 bytes of ASCII.
_MIN_IMAGE_BYTES = 1024

# Versioned-stem regex: captures trailing _vNN.
_VERSION_RE = re.compile(r"_v(\d+)$", re.IGNORECASE)

# Resolver-side extension priority: .jpeg first (real bytes in tartarus often
# .jpeg while broken stubs are .png).
_IMAGE_EXTENSIONS_RESOLVER = (".jpeg", ".png", ".jpg", ".webp")


@dataclass(frozen=True)
class ResolvedRef:
    """Result of a successful resolve_ref() call."""
    path: Path
    cls: str
    subject: str
    kind: str
    variant: Optional[str]
    version: Optional[int]
    integrity_ok: bool


class RefNotFoundError(FileNotFoundError):
    """Raised when resolve_ref() finds no candidate matching (cls,subject,kind,variant)."""


class BrokenRefError(RuntimeError):
    """Raised when a candidate file exists but fails integrity validation."""


def _normalize_subject(subject: str) -> str:
    """Canonicalize a subject id: lowercase, hyphens to underscores."""
    if not subject:
        raise ValueError("subject must be non-empty")
    return subject.lower().replace("-", "_").strip()


def _normalize_class(cls: str) -> str:
    """Collapse class aliases to canonical. Logs warning on alias use."""
    if cls in VALID_ASSET_CLASSES:
        return cls
    canonical = _CLASS_ALIASES.get(cls)
    if canonical is None:
        raise ValueError(
            f"Invalid asset class: {cls!r}. Must be one of "
            f"{sorted(VALID_ASSET_CLASSES)} (or alias of: "
            f"{sorted(_CLASS_ALIASES)})."
        )
    _LOG.warning(
        "resolve_ref: class alias %r -> %r. Migrate caller to canonical name.",
        cls, canonical,
    )
    return canonical


def _parse_version(stem: str) -> tuple:
    """Strip trailing _vNN from a stem. Returns (base_stem, version_int_or_None)."""
    m = _VERSION_RE.search(stem)
    if not m:
        return stem, None
    return stem[: m.start()], int(m.group(1))


def _candidate_stems(subject: str, kind: str, variant: Optional[str]) -> list:
    """Build the priority-ordered stem candidate list."""
    stems = []
    if variant:
        stems.append(f"{subject}_{kind}_{variant}")
    if variant in (None, "hero"):
        stems.append(f"{subject}_{kind}_hero")
    stems.append(f"{subject}_{kind}")
    stems.append(Path(ref_filename(subject_stem(subject), kind)).stem)
    seen = set()
    return [s for s in stems if not (s in seen or seen.add(s))]


def _integrity_check(path: Path) -> None:
    """Validate that path is a real image file. Raises BrokenRefError on failure."""
    try:
        size = path.stat().st_size
    except OSError as e:
        raise BrokenRefError(f"Cannot stat ref file: {path} ({e})") from e
    if size < _MIN_IMAGE_BYTES:
        try:
            preview = path.read_bytes()[:200].decode("utf-8", errors="replace")
        except OSError:
            preview = "<unreadable>"
        raise BrokenRefError(
            f"Ref file too small to be a real image ({size} bytes < "
            f"{_MIN_IMAGE_BYTES}): {path}\n  Content preview: {preview!r}"
        )
    try:
        with path.open("rb") as f:
            head = f.read(12)
    except OSError as e:
        raise BrokenRefError(f"Cannot read ref file: {path} ({e})") from e
    if not any(head.startswith(magic) for magic, _ in _IMAGE_HEADERS):
        raise BrokenRefError(
            f"Ref file has no image header magic. First 12 bytes: {head!r}\n"
            f"  Path: {path}\n  Size: {size} bytes"
        )


def _resolve_one_stem(
    look_dir: Path,
    stem: str,
    allow_versioned: bool,
) -> Optional[tuple]:
    """Find the best file matching stem. Returns (path, version_int_or_None) or None."""
    for ext in _IMAGE_EXTENSIONS_RESOLVER:
        candidate = look_dir / f"{stem}{ext}"
        if candidate.exists():
            return candidate, None
    if allow_versioned:
        best = None
        for ext in _IMAGE_EXTENSIONS_RESOLVER:
            for match in look_dir.glob(f"{stem}_v*{ext}"):
                m = _VERSION_RE.search(match.stem)
                if not m:
                    continue
                if match.stem[: m.start()] != stem:
                    continue
                v = int(m.group(1))
                if best is None or v > best[0]:
                    best = (v, match)
        if best is not None:
            return best[1], best[0]
    return None


@dataclass(frozen=True)
class ProjectPaths:
    """Immutable path bundle for a single project under v3 layout.

    Construct via:
        paths = ProjectPaths.for_project("tartarus")
        paths = ProjectPaths(project_root=Path("/abs/path/to/projects/tartarus"))

    Access (v3):
        paths.assets_dir                              → projects/tartarus/assets/
        paths.asset_class_dir("char")                 → projects/tartarus/assets/char/
        paths.asset_subject_dir("char", "jade")       → projects/tartarus/assets/char/jade/
        paths.asset_look_dir("char", "jade", "base")  → projects/tartarus/assets/char/jade/base/
        paths.pool_dir("char", "jade", "identity")    → .../jade/base/pool/identity/
        paths.resolve_hero("char", "jade", "identity") → .../jade/base/jade_identity.jpeg
        paths.prep_dir                                → projects/tartarus/prep/
        paths.episode_prep_dir(1)                     → projects/tartarus/prep/ep_001/
        paths.renders_dir                             → projects/tartarus/renders/
        paths.episode_renders_dir(1)                  → projects/tartarus/renders/ep_001/
        paths.pipeline_dir                            → projects/tartarus/_pipeline/
        paths.state_dir                               → projects/tartarus/_pipeline/state/
        paths.shot_plans_dir                          → projects/tartarus/_pipeline/shot_plans/
        paths.scripting_dir                           → projects/tartarus/scripting/
        paths.bible_dir                               → projects/tartarus/scripting/bible/
        paths.episodes_dir                            → projects/tartarus/scripting/episodes/
        paths.development_dir                         → projects/tartarus/scripting/development/
        paths.compiled_dir                            → projects/tartarus/scripting/compiled/
        paths.treatment_path                          → projects/tartarus/scripting/treatment.md
        paths.history_dir                             → projects/tartarus/_history/

    Deprecated v2 accessors (raise DeprecatedPathAPIError):
        paths.sequences_dir    → use prep_dir
        paths.asset_kind_dir() → use asset_class_dir/asset_subject_dir/asset_look_dir

    The constructor validates layout_freeze ONCE. Frozen projects (driver-beware)
    raise LayoutFrozenError on any v2-only property access.
    """

    project_root: Path
    layout_freeze: Optional[str] = field(default=None)

    @classmethod
    def for_project(cls, project: Optional[str] = None) -> "ProjectPaths":
        """Construct from a project slug. Looks up projects_root() and reads
        project_config.json for the layout_freeze flag.

        R7.4: surface caller bugs early. Production project slugs are
        lowercase by convention — if a caller passes "Tartarus" instead of
        "tartarus" we still case-fold (preserving backward compat) but log
        a warning so the mismatch is visible. We also raise if the project
        directory does not exist, EXCEPT for slugs that start with "_"
        (test scaffolding such as `_phase14_gate_test`) where the harness
        intentionally constructs paths before mkdir.
        """
        # Read default_project dynamically so reload_pipeline_config() takes
        # effect without re-importing this module (stale module-level constant
        # bug, Debug Round 1 Issue #1).
        default = get_pipeline_config().get("default_project", "leviathan")
        raw = project or default
        if raw != raw.lower():
            import logging
            logging.getLogger(__name__).warning(
                "ProjectPaths.for_project: non-lowercase project name %r — case-folding to %r",
                raw, raw.lower(),
            )
        proj = raw.lower()
        root = projects_root() / proj
        if not root.is_dir() and not proj.startswith("_"):
            raise FileNotFoundError(
                f"Project {proj!r} does not exist at {root}. "
                f"If this is intentional (e.g., constructing paths before mkdir), "
                f"use ProjectPaths(project_root=...) directly."
            )
        freeze = _read_layout_freeze(root)
        return cls(project_root=root, layout_freeze=freeze)

    @classmethod
    def from_root(cls, project_root: Path) -> "ProjectPaths":
        """Construct from an absolute project root path."""
        freeze = _read_layout_freeze(project_root)
        return cls(project_root=project_root, layout_freeze=freeze)

    # ── Project metadata ──

    @property
    def project(self) -> str:
        """Project slug (last directory component)."""
        return self.project_root.name

    @property
    def project_config_path(self) -> Path:
        return self.project_root / "project_config.json"

    def _check_unfrozen(self, prop_name: str) -> None:
        # Normalize comparison so "ad_hoc", "AD-HOC", "Ad Hoc", etc. all map
        # to the canonical "ad-hoc" — prevents silent freeze-marker typos
        # (Debug Round 1 Issue #2).
        if self.layout_freeze and self.layout_freeze.lower().replace("_", "-").replace(" ", "-") == "ad-hoc":
            raise LayoutFrozenError(
                f"Project {self.project!r} has layout_freeze='ad-hoc'. "
                f"Property {prop_name!r} is v2-only and not available. "
                f"Finish this project against its pre-migration git commit."
            )

    # ── assets/ (canonical inputs root) ──

    @property
    def assets_dir(self) -> Path:
        self._check_unfrozen("assets_dir")
        return self.project_root / "assets"

    def asset_kind_dir(self, kind: str, subject: Optional[str] = None) -> Path:
        """DEPRECATED in v3. Use asset_class_dir/asset_subject_dir/asset_look_dir."""
        raise DeprecatedPathAPIError(
            "asset_kind_dir() is deprecated in v3. Use asset_class_dir/asset_subject_dir/asset_look_dir. "
            "See consultations/recoil/ref-taxonomy-and-derivation-2026-05-30/SYNTHESIS.md"
        )

    # ── v3 asset taxonomy (subject-first refs) ──

    def asset_class_dir(self, cls: str) -> Path:
        if cls in _CLASS_ALIASES and _CLASS_ALIASES[cls] != cls:
            canonical = _CLASS_ALIASES[cls]
            raise DeprecatedPathAPIError(
                f"asset_class_dir({cls!r}) is deprecated. "
                f"Use canonical name {canonical!r}. "
                f"(In Phase 1-3 transition window, resolve_ref() collapses the alias "
                f"with a warning; direct dir construction does not.)"
            )
        if cls not in VALID_ASSET_CLASSES:
            raise ValueError(f"Invalid asset class: {cls!r}. Must be one of {sorted(VALID_ASSET_CLASSES)}")
        self._check_unfrozen(f"asset_class_dir({cls!r})")
        return self.project_root / "assets" / cls

    def asset_subject_dir(self, cls: str, subject: str) -> Path:
        return self.asset_class_dir(cls) / subject

    def asset_look_dir(self, cls: str, subject: str, look: str = "base") -> Path:
        return self.asset_subject_dir(cls, subject) / look

    def pool_dir(self, cls: str, subject: str, kind: str, look: str = "base") -> Path:
        if kind not in VALID_REF_TYPES:
            raise ValueError(f"Invalid ref type: {kind!r}")
        return self.asset_look_dir(cls, subject, look) / "pool" / kind

    def pool_4k_dir(self, cls: str, subject: str, kind: str, look: str = "base") -> Path:
        return self.pool_dir(cls, subject, kind, look) / "4k"

    def resolve_ref(self, cls, subject, kind, variant=None, look="base",
                    *, allow_versioned=True, validate=True):
        """Resolve a ref image path through the canonical SSOT resolver.

        Returns ResolvedRef on success.
        Raises RefNotFoundError if no candidate found.
        Raises BrokenRefError if candidate fails integrity check.
        """
        cls_canonical = _normalize_class(cls)
        subject_norm = _normalize_subject(subject)
        if kind not in VALID_REF_TYPES:
            raise ValueError(
                f"Invalid ref kind: {kind!r}. Must be one of {sorted(VALID_REF_TYPES)}."
            )
        look_dir = self.asset_look_dir(cls_canonical, subject_norm, look)
        if not look_dir.is_dir():
            raise RefNotFoundError(
                f"No look directory: {look_dir} "
                f"(cls={cls_canonical!r}, subject={subject_norm!r}, look={look!r})"
            )
        stems = _candidate_stems(subject_norm, kind, variant)
        tried = []
        for stem in stems:
            tried.append(stem)
            found = _resolve_one_stem(look_dir, stem, allow_versioned)
            if found is None:
                continue
            path, version = found
            if validate:
                _integrity_check(path)
            return ResolvedRef(
                path=path, cls=cls_canonical, subject=subject_norm,
                kind=kind, variant=variant, version=version,
                integrity_ok=True,
            )
        raise RefNotFoundError(
            f"No ref found in {look_dir}\n"
            f"  cls={cls_canonical!r} subject={subject_norm!r} kind={kind!r} "
            f"variant={variant!r}\n"
            f"  Stems tried: {tried}\n"
            f"  Extensions tried: {list(_IMAGE_EXTENSIONS_RESOLVER)}\n"
            f"  Versioned glob: {allow_versioned}"
        )

    def resolve_hero(self, cls: str, subject: str, kind: str, look: str = "base") -> Path:
        """Back-compat wrapper. Returns Path (not ResolvedRef). Validates by default."""
        return self.resolve_ref(cls, subject, kind, variant="hero",
                                look=look, validate=True).path

    # ── prep/ (replaces sequences/) ──

    @property
    def prep_dir(self) -> Path:
        self._check_unfrozen("prep_dir")
        return self.project_root / "prep"

    def episode_prep_dir(self, episode: int) -> Path:
        return self.prep_dir / f"ep_{episode:03d}"

    def episode_storyboards_dir(self, episode: int) -> Path:
        return self.episode_prep_dir(episode) / "storyboards"

    def episode_breakdown_dir(self, episode: int) -> Path:
        return self.episode_prep_dir(episode) / "breakdown"

    # ── sequences/ (DEPRECATED — raises) ──

    @property
    def sequences_dir(self) -> Path:
        raise DeprecatedPathAPIError(
            "sequences_dir is deprecated in v3. Use prep_dir instead. "
            "See consultations/recoil/project-layout-v3-migration-2026-05-30/SYNTHESIS.md"
        )

    def episode_sequences_dir(self, episode: int) -> Path:
        return self.sequences_dir / f"ep_{episode:03d}"

    # ── renders/ (final video outputs) ──

    @property
    def renders_dir(self) -> Path:
        self._check_unfrozen("renders_dir")
        return self.project_root / "renders"

    def episode_renders_dir(self, episode: int) -> Path:
        return self.renders_dir / f"ep_{episode:03d}"

    # ── _pipeline/ (pipeline internals, v3) ──

    @property
    def pipeline_dir(self) -> Path:
        return self.project_root / "_pipeline"

    @property
    def shot_plans_dir(self) -> Path:
        return self.pipeline_dir / "shot_plans"

    @property
    def state_dir(self) -> Path:
        self._check_unfrozen("state_dir")
        return self.pipeline_dir / "state"

    @property
    def visual_state_dir(self) -> Path:
        # Read namespace dynamically so reload_pipeline_config() takes effect
        # without re-importing this module (stale module-level constant bug,
        # Debug Round 1 Issue #1).
        namespace = get_pipeline_config().get("visual_state_namespace", "visual")
        return self.state_dir / namespace

    @property
    def manifests_dir(self) -> Path:
        self._check_unfrozen("manifests_dir")
        return self.state_dir / "manifests"

    @property
    def bundles_dir(self) -> Path:
        self._check_unfrozen("bundles_dir")
        return self.state_dir / "bundles"

    # ── non-visual state namespaces (siblings of visual/ under _pipeline/state/) ──

    @property
    def orchestration_dir(self) -> Path:
        return self.state_dir / "orchestration"

    @property
    def orchestration_scenes_dir(self) -> Path:
        return self.orchestration_dir / "scenes"

    @property
    def learning_dir(self) -> Path:
        return self.state_dir / "learning"

    @property
    def checkpoints_dir(self) -> Path:
        return self.state_dir / "checkpoints"

    @property
    def backups_dir(self) -> Path:
        return self.state_dir / "backups"

    @property
    def plans_dir(self) -> Path:
        return self.visual_state_dir / "plans"

    @property
    def passes_dir(self) -> Path:
        return self.visual_state_dir / "passes"

    @property
    def coverage_passes_dir(self) -> Path:
        return self.visual_state_dir / "coverage_passes"

    @property
    def derivation_dir(self) -> Path:
        return self.visual_state_dir / "derivation"

    @property
    def shots_dir(self) -> Path:
        return self.visual_state_dir / "shots"

    @property
    def casting_state_path(self) -> Path:
        return self.visual_state_dir / "casting_state.json"

    @property
    def global_bible_path(self) -> Path:
        return self.visual_state_dir / "global_bible.json"

    @property
    def episode_look_map(self) -> Path:
        return self.visual_state_dir / "episode_look_map.json"

    @property
    def hashcache(self) -> Path:
        return self.visual_state_dir / "_hashcache.json"

    def get_character_sheets_dir(self, char_name: str) -> Path:
        return self.assets_dir / "char" / char_name / "base" / "sheets"

    def get_location_sheets_dir(self, loc_name: str) -> Path:
        return self.assets_dir / "loc" / loc_name / "sheets"

    def sheet_path(self, cls: str, subject: str) -> Path:
        """The canonical composite-sheet file for an entity — the SINGLE per-kind
        sheet-layout home. char -> base/sheets/, loc -> sheets/ (NO base/),
        prop -> subject-root. Layout ONLY; existence/validity is the caller's job.
        This is the one place that may branch on per-kind sheet layout. `cls`
        accepts the same aliases the resolver's _normalize_kind does (v3 class,
        v1 plural, the 'identity'->char alias) plus the singular forms."""
        norm = {
            "char": "char", "characters": "char", "character": "char", "identity": "char",
            "loc": "loc", "locations": "loc", "location": "loc",
            "prop": "prop", "props": "prop",
        }.get(cls, cls)
        subject = subject.lower()
        if norm == "char":
            return self.get_character_sheets_dir(subject) / "sheet_v1.png"
        if norm == "loc":
            return self.get_location_sheets_dir(subject) / "sheet_v1.png"
        return self.asset_subject_dir(norm, subject) / "sheet_v1.png"

    # ── scripting/ (bible, episodes, development, compiled) ──

    @property
    def scripting_dir(self) -> Path:
        return self.project_root / "scripting"

    @property
    def bible_dir(self) -> Path:
        self._check_unfrozen("bible_dir")
        return self.scripting_dir / "bible"

    @property
    def episodes_dir(self) -> Path:
        self._check_unfrozen("episodes_dir")
        return self.scripting_dir / "episodes"

    @property
    def development_dir(self) -> Path:
        self._check_unfrozen("development_dir")
        return self.scripting_dir / "development"

    @property
    def compiled_dir(self) -> Path:
        self._check_unfrozen("compiled_dir")
        return self.scripting_dir / "compiled"

    @property
    def treatment_path(self) -> Path:
        return self.scripting_dir / "treatment.md"

    # ── _history/ ──

    @property
    def history_dir(self) -> Path:
        return self.project_root / "_history"

    @property
    def history_snapshots_dir(self) -> Path:
        return self.history_dir / "snapshots"

    @property
    def history_archives_dir(self) -> Path:
        return self.history_dir / "archives"

    @property
    def history_migration_dir(self) -> Path:
        return self.history_dir / "migration"

    @property
    def history_debug_dir(self) -> Path:
        return self.history_dir / "debug"


def _read_layout_freeze(project_root: Path) -> Optional[str]:
    """Read project_config.json and return layout_freeze value, or None."""
    cfg = project_root / "project_config.json"
    if not cfg.is_file():
        return None
    try:
        data = json.loads(cfg.read_text(encoding="utf-8"))
        return data.get("layout_freeze")
    except (json.JSONDecodeError, OSError):
        return None


# ── Unscoped scratch (outside project tree) ────────────────────────

def scratch_root() -> Path:
    """Global scratch directory — outside any project."""
    return projects_root().parent / "_scratch"


# ── Ref resolution helpers (kept; used by both v1 and v2 callers) ───

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


def resolve_ref(base_dir: Path, stem: str) -> Optional[Path]:
    """Find a ref image by stem, regardless of extension.

    Searches base_dir for {stem}.jpg, .jpeg, .png, .webp.
    Returns the first match or None.
    """
    p = Path(stem)
    if p.suffix.lower() in _IMAGE_EXTENSIONS:
        stem = p.stem
    for ext in _IMAGE_EXTENSIONS:
        candidate = base_dir / f"{stem}{ext}"
        if candidate.exists():
            return candidate
    return None


def resolve_ref_path(project_dir: Path, rel_path: str) -> Optional[Path]:
    """Resolve a relative ref path, tolerant of extension mismatches."""
    exact = project_dir / rel_path
    if exact.exists():
        return exact
    return resolve_ref(exact.parent, exact.stem)


# ── Config loaders ─────────────────────────────────────────────────

def get_pipeline_config() -> dict:
    return _pipeline_config


def reload_pipeline_config():
    global _pipeline_config
    _pipeline_config = _load_pipeline_config()


# Deprecated aliases — kept for one-cycle backwards compat
get_config = get_pipeline_config
reload_config = reload_pipeline_config


# ── Deprecation shims for deleted free functions ───────────────────

def project_output_dir(project: str = None) -> Path:
    """DEPRECATED. The v1 'output/' top-level directory was destroyed in the
    v2 layout refactor (2026-05-26). Use ProjectPaths instead.

    Migration:
        # Old:
        out = project_output_dir(project)
        frames = out / "frames" / f"ep_{ep:03d}"
        # New:
        paths = ProjectPaths.for_project(project)
        frames = paths.episode_prep_dir(ep)        # frames moved to prep/
        # OR (for video):
        videos = paths.episode_renders_dir(ep)     # video moved to renders/
    """
    raise DeprecatedPathAPIError(
        "project_output_dir() was deleted in the v2 layout refactor. "
        "Use ProjectPaths.for_project(project) and the explicit "
        ".prep_dir / .renders_dir / .manifests_dir / .bundles_dir "
        "properties. See recoil/core/paths.py docstring for the v3 layout."
    )


def project_refs_dir(project: str = None) -> Path:
    """DEPRECATED. 'output/refs/' was renamed to 'assets/' and re-keyed by
    singular taxonomy kind in the v2 layout refactor (2026-05-26).

    Migration:
        # Old:
        refs = project_refs_dir(project)
        char_dir = refs / "characters" / "jade"
        # New:
        paths = ProjectPaths.for_project(project)
        char_dir = paths.asset_subject_dir("char", "jade")
    """
    raise DeprecatedPathAPIError(
        "project_refs_dir() was deleted in the v2 layout refactor. "
        "Use ProjectPaths.for_project(project).asset_class_dir(cls) / "
        "asset_subject_dir(cls, subject) / asset_look_dir(cls, subject, look) "
        "with cls in {'char','loc','prop'}."
    )


def refs_characters_dir(project: str, subject: str = None) -> Path:
    """DEPRECATED v2 path: refs/characters/. Use ProjectPaths.asset_subject_dir('char', subject)."""
    raise DeprecatedPathAPIError(
        "refs/characters/ was removed in the v3 layout. "
        f"Use ProjectPaths.for_project({project!r}).asset_subject_dir('char', {subject!r}) "
        "and resolve_ref('char', subject, 'identity', 'hero') for the hero file."
    )


def refs_locations_dir(project: str, subject: str = None) -> Path:
    """DEPRECATED v2 path: refs/locations/."""
    raise DeprecatedPathAPIError(
        "refs/locations/ was removed in the v3 layout. "
        f"Use ProjectPaths.for_project({project!r}).asset_subject_dir('loc', {subject!r})."
    )


def refs_props_dir(project: str, subject: str = None) -> Path:
    """DEPRECATED v2 path: refs/props/."""
    raise DeprecatedPathAPIError(
        "refs/props/ was removed in the v3 layout. "
        f"Use ProjectPaths.for_project({project!r}).asset_subject_dir('prop', {subject!r})."
    )


def output_frames_dir(project: str, episode: int = None) -> Path:
    """DEPRECATED v2 path: output/frames/."""
    raise DeprecatedPathAPIError(
        "output/frames/ was removed in the v2 layout refactor. "
        f"Use ProjectPaths.for_project({project!r})"
        + (f".episode_prep_dir({episode})" if episode else ".prep_dir")
    )


__all__ = [
    # Path constants
    "CONFIG_DIR",
    "CONFIG_PATH",
    "DEFAULT_PROJECT",
    "PIPELINE_ROOT",
    "RECOIL_ROOT",
    "STATE_NAMESPACE",
    "VALID_ASSET_KINDS",
    # v3 constants
    "V3_DIRECTORIES",
    "VALID_ASSET_CLASSES",
    "VALID_REF_TYPES",
    # Exceptions
    "DeprecatedPathAPIError",
    "LayoutFrozenError",
    "ProjectsRootUnresolvable",
    # Bootstrap helpers
    "ensure_pipeline_importable",
    # Config loaders
    "get_config",
    "get_pipeline_config",
    "reload_config",
    "reload_pipeline_config",
    # Project path SSOT
    "ProjectPaths",
    "projects_root",
    "scratch_root",
    # Ref helpers
    "resolve_ref",
    "resolve_ref_path",
    # Resolver types
    "ResolvedRef",
    "RefNotFoundError",
    "BrokenRefError",
    # Deprecation shims (raise on call)
    "project_output_dir",
    "project_refs_dir",
    "refs_characters_dir",
    "refs_locations_dir",
    "refs_props_dir",
    "output_frames_dir",
]
