"""Phase-aware diagnostics for ``okstra doctor --phase``."""
from __future__ import annotations

import json
import os
import subprocess
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable

from okstra_project import ResolverError, project_json_path, resolve_project_root
from okstra_project.resolver import resolve_review_rule_packs

from . import improvement_lenses, worktree_registry
from .models import provider_wrappers
from .workers import resolve_profile_workers
from .worktree import is_git_work_tree, main_worktree_path
from .json_boundary import JsonBoundaryError, load_owned_object

SUPPORTED_PHASES: tuple[str, ...] = (
    "implementation",
    "final-verification",
    "release-handoff",
    "improvement-discovery",
)

_BASE_BRANCHES = {"main", "master", "prod", "preprod", "staging", "dev"}


@dataclass(frozen=True)
class DoctorCheck:
    name: str
    ok: bool
    detail: str

    def to_dict(self) -> dict[str, object]:
        return asdict(self)


def model_pool_diagnostics(
    *,
    host_runtime: str = "claude-code",
    cwd: str | Path | None = None,
) -> dict[str, object]:
    """Diagnose the model pool without making an inference call."""
    from .model_cli import model_diagnostics

    return model_diagnostics(
        host_runtime=host_runtime,
        cwd=None if cwd is None else Path(cwd),
    )


def phase_diagnostics(
    phase: str,
    *,
    cwd: str | Path,
    workspace_root: str | Path,
    home: str | Path,
    host_runtime: str,
) -> dict[str, object]:
    """Return JSON-serialisable readiness checks for one lifecycle phase."""
    if phase not in SUPPORTED_PHASES:
        return {
            "ok": False,
            "usageError": True,
            "reason": _unknown_phase_message(phase),
            "supportedPhases": list(SUPPORTED_PHASES),
            "checks": [],
        }

    workspace = Path(workspace_root)
    home_path = Path(home)
    project_root, checks = _project_checks(Path(cwd))
    checks.append(_profile_check(workspace, phase))

    if project_root is not None:
        checks.extend(
            _phase_checks(
                phase, project_root, workspace, home_path, host_runtime
            )
        )

    return {
        "ok": all(check.ok for check in checks),
        "usageError": False,
        "phase": phase,
        "projectRoot": str(project_root) if project_root else "",
        "checks": [check.to_dict() for check in checks],
    }


def _unknown_phase_message(phase: str) -> str:
    supported = ", ".join(SUPPORTED_PHASES)
    return f"unknown phase '{phase}' (expected one of: {supported})"


def _project_checks(cwd: Path) -> tuple[Path | None, list[DoctorCheck]]:
    try:
        project_root = resolve_project_root(explicit_root="", cwd=str(cwd))
    except ResolverError as exc:
        return None, [_fail("project root", str(exc))]

    checks = [_ok("project root", str(project_root))]
    project_json = project_json_path(project_root)
    if project_json.is_file():
        checks.append(_ok("project registration", str(project_json)))
    else:
        checks.append(
            _fail(
                "project registration",
                f"{project_json} not found — run okstra setup in this project first",
            )
        )
    architecture = _architecture_declaration_check(project_root)
    if architecture is not None:
        checks.append(architecture)
    review_packs = _review_rule_pack_check(project_root)
    if review_packs is not None:
        checks.append(review_packs)
    return project_root, checks


_LAYOUT_SCAN_SKIP = frozenset(
    {".git", ".okstra", "node_modules", "dist", "build", "coverage", "__pycache__"}
)


def _ports_and_adapters_marker(project_root: Path) -> str:
    """The first ports-and-adapters layout signal found, or "".

    Only the two signals the pack states mechanically (`architectures/hexagonal.md`
    Stage 3 row): a `ports/` directory beside a `domain/` one, and a `*.port.*`
    file. The other two — NestJS hex split, abstract classes at a boundary — are
    worker judgment and are deliberately not guessed at here.
    """
    for current, dirnames, filenames in os.walk(project_root):
        dirnames[:] = [d for d in dirnames if d not in _LAYOUT_SCAN_SKIP]
        here = set(dirnames)
        if "ports" in here and "domain" in here:
            return str(Path(current).relative_to(project_root) / "ports")
        for name in filenames:
            if ".port." in name:
                return str(Path(current).relative_to(project_root) / name)
    return ""


def _architecture_declaration_check(project_root: Path) -> DoctorCheck | None:
    """Report placement rules running advisory because no style was declared.

    The routed coding-preflight pack promotes its placement rules from advisory to
    blocking only under `architecture.style` in `project.json`
    (`architectures/hexagonal.md` Severity). With the key absent they stay advisory,
    a verifier records `architecture-style: none`, and the run reads as fully gated
    — the downgrade is invisible to both lead and user. A project that genuinely is
    not ports-and-adapters silences this by declaring `none`, which is why the raw
    value is read here: `resolve_architecture()` maps absent and `none` alike.
    """
    try:
        payload = load_owned_object(
            project_json_path(project_root), artifact="project configuration"
        )
    except (OSError, ValueError):
        return None
    architecture = payload.get("architecture") if isinstance(payload, dict) else None
    declared = architecture.get("style") if isinstance(architecture, dict) else None
    if isinstance(declared, str) and declared.strip():
        return _ok("architecture style", f"declared: {declared.strip()}")
    marker = _ports_and_adapters_marker(project_root)
    if not marker:
        return None
    return _ok(
        "architecture style",
        f"not declared, but the layout shows a ports-and-adapters signal ({marker}) "
        "— placement rules run advisory, so a misplaced domain object or a concrete "
        "adapter injection is recorded rather than blocking. Declare "
        '`architecture: {"style": "hexagonal"}` in .okstra/project.json to gate '
        'them, or `"none"` to say the layout is not ports-and-adapters.',
    )


def _review_rule_pack_check(project_root: Path) -> DoctorCheck | None:
    """Report a declared review rule pack that no worker will be able to open.

    `reviewRulePacks` is what makes a project's own review standard apply
    without every brief citing it, so a stale path costs the whole pack in
    silence: the phase records `project-review-rules: declared <path>
    unreadable` at best, and a run that reviewed against nothing still passes.
    Absent declaration stays quiet — brief-cited packs remain the other, equally
    valid, channel.

    What this cannot check is whether a pack that does resolve was actually read
    and applied; that stays the worker's own `project-review-rules:` record.
    """
    declared = resolve_review_rule_packs(project_root)
    if not declared:
        return None
    missing = [path for path in declared if not Path(path).is_file()]
    if not missing:
        return _ok("review rule packs", f"{len(declared)} declared, all readable")
    return _fail(
        "review rule packs",
        f"declared but not readable: {', '.join(missing)} — phases skip a pack "
        "they cannot open, so the run reviews against fewer rules than declared. "
        f"Fix the path in {project_json_path(project_root)} or drop the entry.",
    )


def _profile_check(workspace: Path, phase: str) -> DoctorCheck:
    profile = _profile_path(workspace, phase)
    if profile.is_file():
        return _ok("profile", str(profile))
    return _fail("profile", f"not found: {profile}")


def _phase_checks(
    phase: str,
    project_root: Path,
    workspace: Path,
    home: Path,
    host_runtime: str,
) -> list[DoctorCheck]:
    if phase == "implementation":
        return _implementation_checks(
            project_root, workspace, home, host_runtime
        )
    if phase == "final-verification":
        return _final_verification_checks(workspace)
    if phase == "release-handoff":
        return _release_handoff_checks(project_root)
    if phase == "improvement-discovery":
        return _improvement_discovery_checks(workspace, home, host_runtime)
    return []


def _implementation_checks(
    project_root: Path,
    workspace: Path,
    home: Path,
    host_runtime: str,
) -> list[DoctorCheck]:
    return [
        _git_work_tree_check(project_root),
        _git_worktree_support_check(project_root),
        _approved_plan_validator_check(),
        *_worker_dispatch_checks(home, workspace, "implementation", host_runtime),
    ]


def _final_verification_checks(workspace: Path) -> list[DoctorCheck]:
    return [
        _worktree_registry_check(),
        _file_check("validation command", workspace / "validators" / "validate-run.py"),
        _file_check(
            "final report template",
            workspace / "templates" / "reports" / "final-report-v2.template.md",
        ),
        _file_check(
            "final report schema",
            workspace / "schemas" / "final-report-v2.0.schema.json",
        ),
    ]


def _release_handoff_checks(project_root: Path) -> list[DoctorCheck]:
    return [
        _gh_auth_check(project_root),
        _git_clean_check(project_root),
        _git_remote_check(project_root),
        _feature_branch_check(project_root),
    ]


def _improvement_discovery_checks(
    workspace: Path,
    home: Path,
    host_runtime: str,
) -> list[DoctorCheck]:
    return [
        *_worker_dispatch_checks(
            home, workspace, "improvement-discovery", host_runtime
        ),
        _lens_whitelist_check(),
    ]


def _git_work_tree_check(project_root: Path) -> DoctorCheck:
    if is_git_work_tree(project_root):
        return _ok("git work tree", str(main_worktree_path(project_root)))
    return _fail("git work tree", f"{project_root} is not inside a git work tree")


def _git_worktree_support_check(project_root: Path) -> DoctorCheck:
    result = _run(["git", "-C", str(project_root), "worktree", "list", "--porcelain"])
    if result.returncode == 0:
        return _ok("git worktree support", "git worktree list succeeded")
    return _fail("git worktree support", _command_detail(result))


def _approved_plan_validator_check() -> DoctorCheck:
    try:
        from .run import _validate_approved_plan  # noqa: F401
    except Exception as exc:
        return _fail("approved-plan validator", str(exc))
    return _ok("approved-plan validator", "okstra plan-validate is available")


def _worker_dispatch_checks(
    home: Path,
    workspace: Path,
    phase: str,
    host_runtime: str,
) -> list[DoctorCheck]:
    workers = resolve_profile_workers(_profile_path(workspace, phase))
    if not workers:
        return [_ok("worker dispatch", "no workers required")]
    if not host_runtime:
        return [_fail("worker dispatch", "resolved host runtime is required")]

    native_provider = {
        "claude-code": "claude",
        "codex": "codex",
        "antigravity": "antigravity",
        "grok": "grok",
        "kimi": "kimi",
    }.get(host_runtime)
    providers = ["claude" if worker == "report-writer" else worker for worker in workers]
    checks: list[DoctorCheck] = []

    if host_runtime == "claude-code":
        native_agents = []
        if "claude" in workers:
            native_agents.append("claude")
        if "report-writer" in workers:
            native_agents.append("report-writer")
        for agent in native_agents:
            checks.append(
                _file_check(
                    f"host-native agent: {agent}", _agent_path(home, agent)
                )
            )

    cli_providers = sorted({
        provider for provider in providers if provider != native_provider
    })
    if cli_providers:
        dispatch_module = _first_file(
            workspace / "scripts" / "okstra_ctl" / "worker_dispatch.py",
            workspace / "python" / "okstra_ctl" / "worker_dispatch.py",
        )
        checks.append(
            _file_check("worker-dispatch entrypoint", dispatch_module)
        )
        wrappers = provider_wrappers("analyser")
        for provider in cli_providers:
            wrapper = wrappers.get(provider, "")
            if not wrapper:
                checks.append(
                    _fail(
                        f"CLI provider strategy: {provider}",
                        "provider registry has no analyser wrapper",
                    )
                )
                continue
            path = _first_file(
                workspace / "scripts" / wrapper,
                workspace / "bin" / wrapper,
            )
            checks.append(_file_check(f"CLI provider strategy: {provider}", path))
    if not checks:
        checks.append(
            _ok("worker dispatch", f"native host owns roster on {host_runtime}")
        )
    return checks


def _first_file(*paths: Path) -> Path:
    return next((path for path in paths if path.is_file()), paths[0])


def _worktree_registry_check() -> DoctorCheck:
    # final-verification resolves stage worktrees through the registry. A corrupt
    # registry.json is silently swallowed by _load() (returns empty), masking every
    # reservation, so the readiness signal must actually parse the on-disk file.
    path = worktree_registry._registry_path()
    if not path.exists():
        return _ok("worktree registry", f"no registry yet ({path})")
    try:
        data = load_owned_object(path, artifact="worktree registry")
    except JsonBoundaryError as exc:
        return _fail("worktree registry", f"{path} is unreadable: {exc}")
    if not isinstance(data, dict) or not isinstance(data.get("tasks"), dict):
        return _fail("worktree registry", f"{path} is missing a valid 'tasks' table")
    return _ok("worktree registry", f"{len(data['tasks'])} task entries ({path})")


def _gh_auth_check(project_root: Path) -> DoctorCheck:
    result = _run(["gh", "auth", "status"], cwd=project_root)
    if result.returncode == 0:
        return _ok("gh auth", "gh auth status succeeded")
    return _fail("gh auth", _command_detail(result))


def _git_clean_check(project_root: Path) -> DoctorCheck:
    # okstra-owned outputs (.okstra, synced dirs, nested stage worktrees) are not
    # readiness blockers, but untracked SOURCE still is. Reuse the shared dirty
    # notion (worktree.is_dirty_excluding_okstra) instead of a blunter second
    # definition of "clean".
    from .worktree import is_dirty_excluding_okstra

    try:
        dirty = is_dirty_excluding_okstra(project_root)
    except Exception as exc:  # git missing / not a repo
        return _fail("git clean tree", str(exc))
    if dirty:
        return _fail(
            "git clean tree", "changes present outside okstra-owned paths"
        )
    return _ok("git clean tree", "working tree clean (excluding okstra-owned paths)")


def _git_remote_check(project_root: Path) -> DoctorCheck:
    result = _run(["git", "-C", str(project_root), "remote", "get-url", "origin"])
    if result.returncode == 0 and result.stdout.strip():
        return _ok("git remote", result.stdout.strip())
    return _fail("git remote", _command_detail(result))


def _feature_branch_check(project_root: Path) -> DoctorCheck:
    result = _run(["git", "-C", str(project_root), "rev-parse", "--abbrev-ref", "HEAD"])
    branch = result.stdout.strip()
    if result.returncode != 0 or not branch:
        return _fail("feature branch", _command_detail(result))
    if branch == "HEAD":
        return _fail("feature branch", "detached HEAD")
    if branch in _BASE_BRANCHES:
        return _fail("feature branch", f"current branch is base branch: {branch}")
    return _ok("feature branch", branch)


def _lens_whitelist_check() -> DoctorCheck:
    lenses = improvement_lenses.LENSES
    if lenses and all(improvement_lenses.is_valid_lens(lens) for lens in lenses):
        return _ok("lens whitelist", ",".join(lenses))
    return _fail("lens whitelist", "invalid or empty lens whitelist")


def _file_check(name: str, path: Path) -> DoctorCheck:
    if path.is_file():
        return _ok(name, str(path))
    return _fail(name, f"not found: {path}")


def _agent_path(home: Path, worker: str) -> Path:
    return home / ".claude" / "agents" / f"{worker}-worker.md"


def _profile_path(workspace: Path, phase: str) -> Path:
    return workspace / "prompts" / "profiles" / f"{phase}.md"


def _run(
    args: Iterable[str],
    *,
    cwd: Path | None = None,
) -> subprocess.CompletedProcess[str]:
    try:
        return subprocess.run(
            list(args),
            cwd=str(cwd) if cwd else None,
            capture_output=True,
            text=True,
            check=False,
        )
    except (OSError, FileNotFoundError) as exc:
        return subprocess.CompletedProcess(list(args), 127, "", str(exc))


def _command_detail(result: subprocess.CompletedProcess[str]) -> str:
    output = (result.stderr or result.stdout).strip()
    if output:
        return output.splitlines()[-1]
    return f"command exited {result.returncode}"


def _ok(name: str, detail: str) -> DoctorCheck:
    return DoctorCheck(name=name, ok=True, detail=detail)


def _fail(name: str, detail: str) -> DoctorCheck:
    return DoctorCheck(name=name, ok=False, detail=detail)
