"""Project adapter — reads projects/{slug}/state/visual/global_bible.json.

Phase 16 contract:
  list_projects() → list[Project]    (loaded projects only; legacy excluded)
  get_project(id) → Project | None   (None on unknown; legacy raises)
  list_legacy_project_warnings()     (slugs that failed to load + reason)

Older Recoil projects predate the global_bible.json contract and are
intentionally NOT supported by Console v2 (per JT decision 2026-05-03 +
spec 16.2). The adapter raises LegacyProjectFormatError on missing/malformed
state; the route boundary catches it and surfaces an EngineEvent warning
instead of synthesizing a fallback shape.

This adapter is READ-ONLY. It never writes to projects/.
"""
from __future__ import annotations

import json
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

from recoil.api.adapters._ids import (
    validate_project_id,
)
from recoil.api.fallback_bridge import emit_fallback
from recoil.api.schemas.engine import (
    SCHEMA_VERSION,
    Project,
)
from recoil.core.paths import projects_root, ProjectPaths
from recoil.core.project import get_project as _core_get_project

logger = logging.getLogger(__name__)


# Slugs that are scaffolding / scratch / hidden — not real projects.
_RESERVED_SLUGS = {
    "_archive",
    "_shared",
    "_phase14_gate_test",
    "_test_r5",
    "_test_r5b",
    "_test_r5c",
    "_test_stratlib",
    "_exec_test_r2",
    "state",
    "test_project",
    "test-project",
    "starsend-test",
}

# Project id contract — must agree with @recoil/contracts ProjectId regex.
_PROJECT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")


class LegacyProjectFormatError(Exception):
    """Raised when a project on disk lacks the canonical state/visual/global_bible.json.

    Per spec 16.2 + JT 2026-05-03: do NOT synthesize a fallback Project shape.
    The route boundary catches this, surfaces an EngineEvent warning, and
    omits the project from the list.
    """

    def __init__(self, slug: str, reason: str) -> None:
        super().__init__(f"legacy project {slug!r}: {reason}")
        self.slug = slug
        self.reason = reason


@dataclass(frozen=True)
class LegacyWarning:
    slug: str
    reason: str


def _is_candidate_slug(name: str) -> bool:
    if name in _RESERVED_SLUGS:
        return False
    if name.startswith("."):
        return False
    if not _PROJECT_ID_RE.match(name):
        return False
    return True


def _global_bible_path(slug: str) -> Path:
    return ProjectPaths.for_project(slug).global_bible_path


def _load_bible(slug: str) -> dict:
    path = _global_bible_path(slug)
    if not path.exists():
        raise LegacyProjectFormatError(
            slug, f"missing {path.relative_to(projects_root())}"
        )
    try:
        with path.open("r", encoding="utf-8") as fh:
            return json.load(fh)
    except (json.JSONDecodeError, OSError) as exc:
        raise LegacyProjectFormatError(slug, f"unreadable global_bible.json: {exc}") from exc


def _resolve_aspect(project_slug: str) -> tuple[str, bool]:
    """Resolve aspect ratio for a project via the SSOT (Project class).

    Returns (aspect, synthesized). Raises AspectUnresolvable if no source
    provides aspect — Law 4: no silent default to "9_16".

    The legacy bible-fallback path now lives inside Project._migrate_legacy
    (Phase 2). This adapter only asks the SSOT.
    """
    proj = _core_get_project(project_slug)
    return (proj.aspect_ratio, proj.aspect_synthesized)


def _project_name(slug: str, bible: dict) -> str:
    raw = bible.get("project") or bible.get("project_name") or slug
    return str(raw)


def _build_project(slug: str, bible: dict) -> Project:
    """Build a minimal Project shape from global_bible.json.

    Build B Phase 7 (2026-05-09): episode synthesis is gated behind
    `Project.supports_episodes` (per SYNTHESIS lock 16). Microdrama
    projects continue to synthesize from set(shot.episode_id).
    Client_video / client_deliverable projects return episodes=[]
    — the wire shape is honest about the project shape rather than
    laundering filename-prefix junk through a sanctioned fallback.
    """
    from recoil.api.adapters.beats import list_episodes  # local import — avoid cycle on cold start

    name = _project_name(slug, bible)
    aspect, aspect_synthesized = _resolve_aspect(slug)
    # Spec-review C3 (2026-05-10): wrap _core_get_project in try/except so
    # legacy projects raising AspectUnresolvable don't 500 the API.
    try:
        proj_obj = _core_get_project(slug)
    except Exception as e:
        _emit_project_load_failure(slug, e)
        proj_obj = None
    if proj_obj is not None and proj_obj.supports_episodes:
        # Build A Phase 4: episode synthesis is the canonical path for microdrama
        # projects, not a fallback. For client_video projects, return episodes=[].
        episodes = list_episodes(slug)
    else:
        episodes = []
    return Project(
        schema_version=SCHEMA_VERSION,
        id=slug,
        name=name,
        aspect=aspect,
        aspect_synthesized=aspect_synthesized,
        project_type=proj_obj._project_type_enum.value if proj_obj is not None else "microdrama",
        score=None,
        episodes=episodes,
    )


def _slugs_in_root() -> list[str]:
    root = projects_root()
    if not root.exists():
        return []
    out: list[str] = []
    for child in sorted(root.iterdir()):
        if not child.is_dir():
            continue
        if not _is_candidate_slug(child.name):
            continue
        out.append(child.name)
    return out


def _emit_project_load_failure(slug: str, exc: BaseException) -> None:
    """Fire the registered `project_load_failure_isolated` fallback for one slug.

    Centralizes the payload schema so that all three callsites in this
    module stay in sync if the schema ever changes. Callers handle their
    own logger.warning + control-flow (continue / append warning).
    """
    emit_fallback(
        "project_load_failure_isolated",
        scope="api/adapters/projects",
        payload={
            "project_id": slug,
            "error": str(exc),
            "error_type": type(exc).__name__,
        },
    )


def list_projects() -> list[Project]:
    """List projects that load cleanly. Legacy projects are excluded.

    Caller can inspect `list_legacy_project_warnings()` to find the excluded
    slugs (the route surfaces them as EngineEvents).

    A single bad project (Pydantic ValidationError, OSError, late
    LegacyProjectFormatError from inside list_episodes(slug), etc.) MUST
    NOT 500 the whole endpoint — it fires `project_load_failure_isolated`
    and is dropped from the response.
    """
    projects: list[Project] = []
    for slug in _slugs_in_root():
        try:
            bible = _load_bible(slug)
        except LegacyProjectFormatError:
            continue
        except Exception as exc:  # noqa: BLE001
            _emit_project_load_failure(slug, exc)
            logger.warning(
                "list_projects: skipping %r — unexpected %s during _load_bible: %s",
                slug,
                type(exc).__name__,
                exc,
            )
            continue
        try:
            projects.append(_build_project(slug, bible))
        except Exception as exc:  # noqa: BLE001
            _emit_project_load_failure(slug, exc)
            logger.warning(
                "list_projects: skipping %r — unexpected %s during _build_project: %s",
                slug,
                type(exc).__name__,
                exc,
            )
            continue
    return projects


def list_legacy_project_warnings() -> list[LegacyWarning]:
    """Return slugs that look like projects but lack global_bible.json.

    An unexpected exception from `_load_bible` on one slug does NOT
    short-circuit the loop — the slug is recorded as
    `LegacyWarning(reason="unexpected: {exc_type}")` and the loop
    continues for the remaining slugs.
    """
    out: list[LegacyWarning] = []
    for slug in _slugs_in_root():
        try:
            _load_bible(slug)
        except LegacyProjectFormatError as exc:
            out.append(LegacyWarning(slug=slug, reason=exc.reason))
        except Exception as exc:  # noqa: BLE001
            _emit_project_load_failure(slug, exc)
            logger.warning(
                "list_legacy_project_warnings: continuing past %r — unexpected %s "
                "during _load_bible: %s",
                slug,
                type(exc).__name__,
                exc,
            )
            out.append(LegacyWarning(slug=slug, reason=f"unexpected: {type(exc).__name__}"))
    return out


def get_project(project_id: str) -> Optional[Project]:
    """Load one project. Returns None for unknown slugs.

    Raises LegacyProjectFormatError if the slug exists on disk but lacks
    a global_bible.json — the route surfaces this as 410 Gone-ish (mapped
    to a warning event).

    Path-traversal guard (Debug R1): malformed slugs raise ValueError
    BEFORE we hit the filesystem. Reserved-slug rejection still returns
    None (a known not-a-project answer, not a malformed input).
    """
    validate_project_id(project_id)
    if not _is_candidate_slug(project_id):
        return None
    if not (projects_root() / project_id).is_dir():
        return None
    bible = _load_bible(project_id)  # may raise
    return _build_project(project_id, bible)


__all__ = [
    "LegacyProjectFormatError",
    "LegacyWarning",
    "list_projects",
    "list_legacy_project_warnings",
    "get_project",
]
