"""Load and validate the web UI project registry (#427, evolved for #635).

Phase 1 of the console architecture ADR keeps this loader read-only. It owns
the versioned project registry contract served at ``/api/v1/projects``:

* the on-disk file carries a ``version`` (schema version 1 or 2);
* version 1 files stay loadable and are normalized with explicit defaults, so
  an operator registry written for #427 keeps working;
* every validation failure raises :class:`RegistryError`, which carries an
  actionable ``remediation`` string instead of leaking a traceback;
* serialization never emits credentials — credential-shaped keys are rejected
  at load time, before any DTO is built.
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from webui.registry_safety import is_forbidden_key

#: Version of the JSON contract served under ``/api/v1/...``.
REGISTRY_API_VERSION = "v1"

#: Schema version written by this repository's packaged registry.
CURRENT_SCHEMA_VERSION = 2

#: Schema versions this loader accepts. Version 1 is normalized on load.
SUPPORTED_SCHEMA_VERSIONS = (1, 2)

#: Lifecycle state of a registered project.
PROJECT_STATUSES = ("active", "onboarding", "paused", "archived")
_DEFAULT_PROJECT_STATUS = "active"

#: Completion state of a single onboarding step.
ONBOARDING_STATES = ("complete", "pending", "blocked", "not_applicable")
_DEFAULT_ONBOARDING_STATE = "pending"

#: Redacted, last-seen health of a project's control plane.
HEALTH_STATUSES = ("healthy", "degraded", "unreachable", "unknown")

class RegistryError(ValueError):
    """A registry file could not be loaded or failed validation.

    Carries an operator-facing ``remediation`` so routes can fail closed with
    an actionable message rather than a stack trace.
    """

    def __init__(
        self,
        message: str,
        *,
        remediation: str,
        source_path: Path | None = None,
        field_path: str | None = None,
    ) -> None:
        super().__init__(message)
        self.message = message
        self.remediation = remediation
        self.source_path = source_path
        self.field_path = field_path

    def to_dict(self) -> dict[str, Any]:
        """Serialize for a fail-closed JSON error response."""
        return {
            "error": "registry_invalid",
            "detail": self.message,
            "remediation": self.remediation,
            "field_path": self.field_path,
            "source_path": str(self.source_path) if self.source_path else None,
        }


_REQUIRED_PROJECT_FIELDS = (
    "id",
    "repo_name",
    "gitea_owner",
    "remote_host",
    "default_branch",
    "local_checkout_path",
    "profiles",
    "workflow_paths",
)

_REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler")


@dataclass(frozen=True)
class OnboardingStep:
    id: str
    title: str
    description: str
    state: str = _DEFAULT_ONBOARDING_STATE
    required: bool = True


@dataclass(frozen=True)
class OnboardingSummary:
    """Aggregate onboarding progress for a single project."""

    total: int
    complete: int
    pending: int
    blocked: int
    not_applicable: int
    required_outstanding: int
    onboarding_complete: bool


@dataclass(frozen=True)
class ProjectHealth:
    """Redacted last-seen health. Never carries endpoints or credentials."""

    status: str
    checked_at: str | None
    detail: str | None


@dataclass(frozen=True)
class ProjectRecord:
    id: str
    repo_name: str
    gitea_owner: str
    remote_host: str
    default_branch: str
    local_checkout_path: str
    profiles: dict[str, str]
    workflow_paths: dict[str, str]
    schema_paths: dict[str, str]
    onboarding_checklist: tuple[OnboardingStep, ...]
    status: str = _DEFAULT_PROJECT_STATUS
    remote_name: str | None = None
    last_seen_health: ProjectHealth | None = None

    @property
    def repo_full_name(self) -> str:
        return f"{self.gitea_owner}/{self.repo_name}"


@dataclass(frozen=True)
class ProjectRegistry:
    version: int
    projects: tuple[ProjectRecord, ...]
    source_path: Path

    @property
    def schema_version(self) -> int:
        """Alias of :attr:`version` — the schema version read from disk."""
        return self.version

    @property
    def api_version(self) -> str:
        return REGISTRY_API_VERSION


def default_registry_path() -> Path:
    override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
    if override:
        return Path(override).expanduser().resolve()
    return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()


def _reject_credential_keys(obj: Any, *, path: str = "", source: Path | None = None) -> None:
    """Recursive credential-key guard that reports an actionable ``field_path``.

    Key *shape* is decided by :func:`webui.registry_safety.is_forbidden_key`, the
    single source of truth shared with the worker registry (#798).
    """
    if isinstance(obj, dict):
        for key, value in obj.items():
            key_path = f"{path}.{key}" if path else key
            if is_forbidden_key(key):
                raise RegistryError(
                    f"registry must not store credentials ({key_path})",
                    remediation=(
                        f"Remove the credential-shaped key '{key_path}' from the registry. "
                        "Tokens live in the keychain and are resolved server-side by "
                        "gitea_auth; the registry is redacted metadata only."
                    ),
                    source_path=source,
                    field_path=key_path,
                )
            _reject_credential_keys(value, path=key_path, source=source)
    elif isinstance(obj, list):
        for index, item in enumerate(obj):
            _reject_credential_keys(item, path=f"{path}[{index}]", source=source)


def _require_enum(
    value: Any,
    *,
    allowed: tuple[str, ...],
    field_path: str,
    source: Path | None,
) -> str:
    text = str(value)
    if text not in allowed:
        raise RegistryError(
            f"{field_path} must be one of {', '.join(allowed)} (got {text!r})",
            remediation=(
                f"Set {field_path} to one of: {', '.join(allowed)}. "
                "Unknown values fail closed so the console never renders an "
                "unverified state."
            ),
            source_path=source,
            field_path=field_path,
        )
    return text


def _parse_onboarding(
    raw: Any,
    *,
    project_path: str,
    source: Path | None,
) -> tuple[OnboardingStep, ...]:
    if raw is None:
        return ()
    if not isinstance(raw, list):
        raise RegistryError(
            f"{project_path}.onboarding_checklist must be an array",
            remediation=(
                f"Rewrite {project_path}.onboarding_checklist as a JSON array of "
                "steps with id, title, description, and optional state."
            ),
            source_path=source,
            field_path=f"{project_path}.onboarding_checklist",
        )
    steps: list[OnboardingStep] = []
    for index, item in enumerate(raw):
        step_path = f"{project_path}.onboarding_checklist[{index}]"
        if not isinstance(item, dict):
            raise RegistryError(
                f"{step_path} must be an object",
                remediation=f"Rewrite {step_path} as an object with id, title, description.",
                source_path=source,
                field_path=step_path,
            )
        missing = [field for field in ("id", "title", "description") if field not in item]
        if missing:
            raise RegistryError(
                f"{step_path} missing required fields: {', '.join(missing)}",
                remediation=(
                    f"Add {', '.join(missing)} to {step_path}. Every onboarding step "
                    "must be self-describing for an operator who has no chat history."
                ),
                source_path=source,
                field_path=step_path,
            )
        state = _require_enum(
            item.get("state", _DEFAULT_ONBOARDING_STATE),
            allowed=ONBOARDING_STATES,
            field_path=f"{step_path}.state",
            source=source,
        )
        steps.append(
            OnboardingStep(
                id=str(item["id"]),
                title=str(item["title"]),
                description=str(item["description"]),
                state=state,
                required=bool(item.get("required", True)),
            )
        )
    return tuple(steps)


def _parse_health(
    raw: Any,
    *,
    project_path: str,
    source: Path | None,
) -> ProjectHealth | None:
    if raw is None:
        return None
    if not isinstance(raw, dict):
        raise RegistryError(
            f"{project_path}.last_seen_health must be an object when present",
            remediation=(
                f"Rewrite {project_path}.last_seen_health as an object with status "
                f"(one of {', '.join(HEALTH_STATUSES)}), optional checked_at and detail, "
                "or remove it. Never store endpoints or credentials here."
            ),
            source_path=source,
            field_path=f"{project_path}.last_seen_health",
        )
    status = _require_enum(
        raw.get("status", "unknown"),
        allowed=HEALTH_STATUSES,
        field_path=f"{project_path}.last_seen_health.status",
        source=source,
    )
    checked_at = raw.get("checked_at")
    detail = raw.get("detail")
    return ProjectHealth(
        status=status,
        checked_at=str(checked_at) if checked_at is not None else None,
        detail=str(detail) if detail is not None else None,
    )


def _parse_project(raw: Any, *, index: int, source: Path | None) -> ProjectRecord:
    project_path = f"projects[{index}]"
    if not isinstance(raw, dict):
        raise RegistryError(
            f"{project_path} must be an object",
            remediation=f"Rewrite {project_path} as a JSON object describing one project.",
            source_path=source,
            field_path=project_path,
        )

    missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
    if missing:
        raise RegistryError(
            f"{project_path} missing required fields: {', '.join(missing)}",
            remediation=(
                f"Add {', '.join(missing)} to {project_path}. See "
                "docs/webui-project-registry-api.md for the field-by-field contract."
            ),
            source_path=source,
            field_path=project_path,
        )

    profiles = raw["profiles"]
    if not isinstance(profiles, dict):
        raise RegistryError(
            f"{project_path}.profiles must be an object",
            remediation=(
                f"Rewrite {project_path}.profiles as an object mapping "
                f"{', '.join(_REQUIRED_PROFILE_ROLES)} to MCP profile names."
            ),
            source_path=source,
            field_path=f"{project_path}.profiles",
        )
    for role in _REQUIRED_PROFILE_ROLES:
        if role not in profiles or not profiles[role]:
            raise RegistryError(
                f"{project_path}.profiles.{role} is required",
                remediation=(
                    f"Set {project_path}.profiles.{role} to the configured MCP profile "
                    "name for that role. Role separation is a workflow-safety invariant."
                ),
                source_path=source,
                field_path=f"{project_path}.profiles.{role}",
            )

    workflow_paths = raw["workflow_paths"]
    if not isinstance(workflow_paths, dict) or not workflow_paths:
        raise RegistryError(
            f"{project_path}.workflow_paths must be a non-empty object",
            remediation=(
                f"Add at least a 'skill' entry to {project_path}.workflow_paths pointing "
                "at the project's canonical workflow skill."
            ),
            source_path=source,
            field_path=f"{project_path}.workflow_paths",
        )

    schema_paths = raw.get("schema_paths") or {}
    if not isinstance(schema_paths, dict):
        raise RegistryError(
            f"{project_path}.schema_paths must be an object when present",
            remediation=(
                f"Rewrite {project_path}.schema_paths as an object of label to repo path, "
                "or remove it."
            ),
            source_path=source,
            field_path=f"{project_path}.schema_paths",
        )

    status = _require_enum(
        raw.get("status", _DEFAULT_PROJECT_STATUS),
        allowed=PROJECT_STATUSES,
        field_path=f"{project_path}.status",
        source=source,
    )
    remote_name = raw.get("remote_name")

    return ProjectRecord(
        id=str(raw["id"]),
        repo_name=str(raw["repo_name"]),
        gitea_owner=str(raw["gitea_owner"]),
        remote_host=str(raw["remote_host"]),
        default_branch=str(raw["default_branch"]),
        local_checkout_path=str(raw["local_checkout_path"]),
        profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
        workflow_paths={key: str(value) for key, value in workflow_paths.items()},
        schema_paths={key: str(value) for key, value in schema_paths.items()},
        onboarding_checklist=_parse_onboarding(
            raw.get("onboarding_checklist"),
            project_path=project_path,
            source=source,
        ),
        status=status,
        remote_name=str(remote_name) if remote_name else None,
        last_seen_health=_parse_health(
            raw.get("last_seen_health"),
            project_path=project_path,
            source=source,
        ),
    )


def load_registry(path: Path | None = None) -> ProjectRegistry:
    """Load the versioned project registry from disk.

    Raises:
        RegistryError: whenever the file is unreadable, is not valid JSON, or
            fails schema validation. The error carries an operator remediation.
    """
    source = (path or default_registry_path()).resolve()
    try:
        raw_text = source.read_text(encoding="utf-8")
    except OSError as exc:
        raise RegistryError(
            f"registry file could not be read: {exc.strerror or exc}",
            remediation=(
                f"Create a readable registry at {source}, or point "
                "WEBUI_PROJECT_REGISTRY at an existing file."
            ),
            source_path=source,
        ) from exc

    try:
        payload = json.loads(raw_text)
    except json.JSONDecodeError as exc:
        raise RegistryError(
            f"registry is not valid JSON: {exc.msg} (line {exc.lineno}, column {exc.colno})",
            remediation=(
                f"Fix the JSON syntax in {source} at line {exc.lineno}, column {exc.colno}."
            ),
            source_path=source,
        ) from exc

    if not isinstance(payload, dict):
        raise RegistryError(
            "registry root must be an object",
            remediation=(
                "Wrap the registry in a JSON object with 'version' and 'projects' keys."
            ),
            source_path=source,
        )

    version = payload.get("version")
    if version not in SUPPORTED_SCHEMA_VERSIONS:
        supported = ", ".join(str(item) for item in SUPPORTED_SCHEMA_VERSIONS)
        raise RegistryError(
            f"unsupported registry version: {version!r}",
            remediation=(
                f"Set 'version' to one of {supported} (current schema is "
                f"{CURRENT_SCHEMA_VERSION}). Migration notes live in "
                "docs/webui-project-registry-api.md."
            ),
            source_path=source,
            field_path="version",
        )

    _reject_credential_keys(payload, source=source)

    projects_raw = payload.get("projects")
    if not isinstance(projects_raw, list) or not projects_raw:
        raise RegistryError(
            "projects must be a non-empty array",
            remediation=(
                "Add at least one project object to 'projects'. An empty console "
                "registry fails closed rather than rendering a blank inventory."
            ),
            source_path=source,
            field_path="projects",
        )

    projects = tuple(
        _parse_project(item, index=index, source=source)
        for index, item in enumerate(projects_raw)
    )
    return ProjectRegistry(version=int(version), projects=projects, source_path=source)


def onboarding_summary(project: ProjectRecord) -> OnboardingSummary:
    """Aggregate a project's onboarding checklist state."""
    steps = project.onboarding_checklist
    counts = {state: 0 for state in ONBOARDING_STATES}
    for step in steps:
        counts[step.state] += 1
    required_outstanding = sum(
        1
        for step in steps
        if step.required and step.state in ("pending", "blocked")
    )
    return OnboardingSummary(
        total=len(steps),
        complete=counts["complete"],
        pending=counts["pending"],
        blocked=counts["blocked"],
        not_applicable=counts["not_applicable"],
        required_outstanding=required_outstanding,
        onboarding_complete=required_outstanding == 0,
    )


def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
    """Serialize a project for JSON API responses and HTML views.

    The HTML views render from this same DTO, so the console and the API can
    never disagree about a project's status or onboarding progress.
    """
    summary = onboarding_summary(project)
    health = project.last_seen_health
    return {
        "id": project.id,
        "repo_name": project.repo_name,
        "gitea_owner": project.gitea_owner,
        "repo_full_name": project.repo_full_name,
        "remote_host": project.remote_host,
        "remote_name": project.remote_name,
        "default_branch": project.default_branch,
        "local_checkout_path": project.local_checkout_path,
        "status": project.status,
        "profiles": dict(project.profiles),
        "workflow_paths": dict(project.workflow_paths),
        "schema_paths": dict(project.schema_paths),
        "onboarding_checklist": [
            {
                "id": step.id,
                "title": step.title,
                "description": step.description,
                "state": step.state,
                "required": step.required,
            }
            for step in project.onboarding_checklist
        ],
        "onboarding_summary": {
            "total": summary.total,
            "complete": summary.complete,
            "pending": summary.pending,
            "blocked": summary.blocked,
            "not_applicable": summary.not_applicable,
            "required_outstanding": summary.required_outstanding,
            "onboarding_complete": summary.onboarding_complete,
        },
        "last_seen_health": (
            None
            if health is None
            else {
                "status": health.status,
                "checked_at": health.checked_at,
                "detail": health.detail,
            }
        ),
    }


def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
    """Serialize the whole registry, including API provenance (#632 section 6)."""
    return {
        "api_version": registry.api_version,
        "schema_version": registry.schema_version,
        # Retained for the unversioned MVP alias consumers (#427).
        "version": registry.version,
        "source_path": str(registry.source_path),
        "source": {
            "kind": "file",
            "path": str(registry.source_path),
            "inventory_complete": True,
        },
        "project_count": len(registry.projects),
        "projects": [project_to_dict(project) for project in registry.projects],
    }


def project_detail_to_dict(
    registry: ProjectRegistry,
    project: ProjectRecord,
) -> dict[str, Any]:
    """Serialize a single project for ``/api/v1/projects/{project_id}``."""
    return {
        "api_version": registry.api_version,
        "schema_version": registry.schema_version,
        "source": {
            "kind": "file",
            "path": str(registry.source_path),
            "inventory_complete": True,
        },
        "project": project_to_dict(project),
    }


def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
    for project in registry.projects:
        if project.id == project_id:
            return project
    return None


def known_project_ids(registry: ProjectRegistry) -> list[str]:
    return [project.id for project in registry.projects]
