"""PROJECT_ROOT 해석과 project.json 갱신 로직."""
from __future__ import annotations

import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from .dirs import (
    OKSTRA_DIR_NAME,
    PROJECT_JSON_RELATIVE,
    project_json_path,
)


class ResolverError(Exception):
    """PROJECT_ROOT 해석 또는 project.json 충돌 실패."""


def _now_iso() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _ancestor_with_project_json(start: Path) -> Optional[Path]:
    """start 부터 위로 올라가며 okstra `project.json` 보유 디렉터리를 찾는다."""
    cur = Path(start).resolve()
    while True:
        if (cur / PROJECT_JSON_RELATIVE).is_file():
            return cur
        if cur.parent == cur:
            return None
        cur = cur.parent


def _git_toplevel(cwd: Path) -> Optional[Path]:
    try:
        rc = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            cwd=str(cwd), capture_output=True, text=True, check=False,
        )
    except (OSError, FileNotFoundError):
        return None
    if rc.returncode != 0:
        return None
    line = rc.stdout.strip()
    if not line:
        return None
    p = Path(line)
    return p if p.is_dir() else None


def resolve_project_root(*, explicit_root: str = "",
                          cwd: Optional[str] = None) -> Path:
    """PROJECT_ROOT 를 해석한다.

    우선순위:
      1. explicit_root (CLI `--project-root`) — 비어있지 않으면 그대로 절대화.
      2. cwd 또는 그 조상 중 `<dir>/project.json` 보유 디렉터리 (dir = OKSTRA_DIR_NAME).
      3. cwd 의 `git rev-parse --show-toplevel`.
    셋 다 실패하면 ResolverError.
    """
    if explicit_root:
        p = Path(explicit_root).expanduser().resolve()
        if not p.is_dir():
            raise ResolverError(
                f"--project-root path does not exist or is not a directory: {p}")
        return p
    cwd_path = Path(cwd or os.getcwd()).resolve()
    ancestor = _ancestor_with_project_json(cwd_path)
    if ancestor is not None:
        return ancestor
    git_top = _git_toplevel(cwd_path)
    if git_top is not None:
        return git_top.resolve()
    raise ResolverError(
        "could not resolve PROJECT_ROOT from cwd. "
        "Pass --project-root <abs-path>, "
        f"run from inside a project (a directory with {OKSTRA_DIR_NAME}/project.json at or above cwd), "
        "or run from inside a git working tree. "
        "(PROJECT_ROOT 를 해석할 수 없습니다 — --project-root 를 명시하거나, "
        "프로젝트 루트 또는 그 하위에서 실행하거나, git 작업 트리 안에서 실행해 주십시오.)")


_ARCHITECTURE_STYLES = frozenset({"hexagonal", "layered", "none"})


def resolve_architecture(project_root: Path | str) -> str:
    """Return the declared architecture style, else ``"none"``.

    Mirrors resolve_build_tool_tokens: any read/parse failure or an
    unrecognised value falls back to the neutral ``"none"`` so an
    unconfigured project keeps the layer-1 behaviour only.
    """
    try:
        payload = json.loads(project_json_path(Path(project_root)).read_text(encoding="utf-8"))
    # ValueError covers json.JSONDecodeError and UnicodeDecodeError alike — a
    # project.json hand-saved in a non-UTF-8 encoding must fall back, not raise.
    except (OSError, ValueError):
        return "none"
    if not isinstance(payload, dict):
        return "none"
    architecture = payload.get("architecture")
    style = architecture.get("style") if isinstance(architecture, dict) else None
    if not isinstance(style, str):
        return "none"
    return style if style in _ARCHITECTURE_STYLES else "none"


def resolve_review_rule_packs(project_root: Path | str) -> tuple[str, ...]:
    """Return the project's declared review rule pack paths, else ``()``.

    A review rule pack is a project's own review standard — the file a phase
    reads before judging a diff. It used to reach a run only when the task
    brief cited its exact path, so a team standard applied or not depending on
    who wrote the brief. Declaring it here makes it apply to every run in the
    project; the brief citation still works and the two are a union.

    Mirrors resolve_architecture's failure posture: any read/parse problem or a
    non-list value degrades to "none declared" rather than raising inside a run.
    An entry that is not an absolute path is dropped, because workers run with
    a worktree as cwd — a relative path would resolve against a tree that does
    not contain the pack, and would mean a different file per worker.
    """
    try:
        payload = json.loads(project_json_path(Path(project_root)).read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return ()
    if not isinstance(payload, dict):
        return ()
    declared = payload.get("reviewRulePacks")
    if not isinstance(declared, list):
        return ()
    packs = []
    for entry in declared:
        if not isinstance(entry, str) or not entry.strip():
            continue
        candidate = Path(entry.strip()).expanduser()
        if candidate.is_absolute():
            packs.append(str(candidate))
    return tuple(dict.fromkeys(packs))


def upsert_project_json(project_root: Path, project_id: str, *,
                         now: Optional[str] = None) -> dict:
    """project.json 을 읽거나 새로 만든다.

    - 파일이 있으면 projectId 가 인자와 일치해야 한다(불일치 시 ResolverError).
      projectRoot 는 현재 절대경로로 갱신, updatedAt 도 갱신.
    - 파일이 없으면 디렉터리를 만들고 4-필드 JSON 을 작성한다.
    반환값은 결과 dict.
    """
    if not project_id:
        raise ResolverError("project_id is required for upsert_project_json")
    target = project_json_path(project_root)
    target.parent.mkdir(parents=True, exist_ok=True)
    when = now or _now_iso()
    abs_root = str(Path(project_root).resolve())
    if target.is_file():
        try:
            data = json.loads(target.read_text())
        except (OSError, json.JSONDecodeError) as exc:
            raise ResolverError(
                f"failed to read project.json at {target}: {exc} "
                f"(project.json 을 읽을 수 없습니다.) "
                f"If the file is corrupted, delete it and re-run 'okstra setup --project-id <id>'."
            ) from exc
        existing_id = str(data.get("projectId") or "")
        if existing_id and existing_id != project_id:
            raise ResolverError(
                f"projectId mismatch: existing project.json has {existing_id!r} "
                f"but the supplied argument is {project_id!r}. "
                f"okstra allows only one projectId per PROJECT_ROOT. "
                f"To fix: re-run with --project-id {existing_id!r} to keep the existing registration, "
                f"or manually delete {target} if you intend to re-register this directory "
                f"under a different id. "
                f"(projectId 불일치: 한 PROJECT_ROOT 에는 하나의 projectId 만 허용됩니다.)")
        # Preserve any user-managed fields (e.g. `worktreeSyncDirs`,
        # `qaCommands`, `prTemplatePath`, `reportLanguage`, `mcpServers`) so
        # manual edits to project.json are not wiped by the per-run
        # self-registration upsert. Only the canonical identity/timestamp
        # fields below are owned by this function.
        result = dict(data) if isinstance(data, dict) else {}
        result["projectId"] = project_id
        result["projectRoot"] = abs_root
        result["createdAt"] = str(data.get("createdAt") or when)
        result["updatedAt"] = when
    else:
        result = {
            "projectId": project_id,
            "projectRoot": abs_root,
            "createdAt": when,
            "updatedAt": when,
        }
    tmp = target.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
    os.replace(tmp, target)
    return result
