"""다음 Phase 포인터의 shape · 승격 · 투영 SSOT.

포인터는 `{phase, status, rationale}` 구조체다. 저작은 리드(report-writer)가
하고, Phase 7 검증기가 리포트의 Phase 라우팅과 대조해 어긋나면 정정한다.
이 모듈은 세 가지만 소유한다 — 구조체 판별, 구형 문자열 승격, 리포트 투영.
다음 phase 를 계산하는 다른 알고리즘을 다시 만들지 않는다.
"""
from __future__ import annotations

from typing import Any, Mapping

from okstra_ctl.clarification_items import (
    APPROVAL_BLOCKS,
    progress_blocking_ids,
)

STATUS_READY = "ready"
STATUS_PENDING = "pending"
STATUS_BLOCKED = "blocked"
STATUS_TERMINAL = "terminal"

POINTER_STATUSES = frozenset(
    {STATUS_READY, STATUS_PENDING, STATUS_BLOCKED, STATUS_TERMINAL}
)

# 구형 문자열 포인터가 쓰던 센티널 → status. 이 표는 승격에만 쓰이며
# 새 포인터를 만드는 데는 절대 쓰지 않는다.
_LEGACY_SENTINELS = {
    "": STATUS_PENDING,
    "unknown": STATUS_PENDING,
    "pending-routing-decision": STATUS_PENDING,
    "pending-release-handoff": STATUS_PENDING,
    "done-or-follow-up": STATUS_TERMINAL,
}

# 라우팅 필드가 없어 항상 terminal 인 task-type.
_TERMINAL_TASK_TYPES = frozenset({"release-handoff"})

# 라우팅 필드가 없어 항상 pending 인 사이드트랙 task-type.
_SIDETRACK_TASK_TYPES = frozenset(
    {
        "improvement-discovery",
        "project-analysis",
        "feature-analysis",
        "change-impact-analysis",
    }
)

# project() 가 명시 분기로 다루는 task-type 전체를 손으로 적은 선언.
# project() 는 이 집합을 읽지 않는다 — 분기는 아래에 그대로 나열돼 있다.
#
# 이 집합을 대조하는 계약 테스트는 PHASE_SEQUENCE 에 있으면서 여기 없는 phase 만
# 잡아낸다. 선언 두 개를 비교하는 것이므로, 여기에 이름을 적고 project() 에 분기를
# 안 넣으면 그 phase 는 여전히 pending 폴백으로 조용히 흐른다. 분기의 실재는
# tests/contract/test_next_phase_projection.py 의
# test_project_has_a_live_branch_for_every_lifecycle_phase 가 정상 리포트를 실제로
# 투영해 확인한다.
HANDLED_TASK_TYPES = frozenset(
    _TERMINAL_TASK_TYPES
    | _SIDETRACK_TASK_TYPES
    | {
        "requirements-discovery",
        "error-analysis",
        "implementation-option-selection",
        "implementation-planning",
        "implementation",
        "final-verification",
    }
)

# implementation-option-selection 의 routing enum 중 phase 가 아닌 값.
_OPTION_SELECTION_NON_PHASE = {
    "pending-direction-selection": STATUS_PENDING,
    "blocked": STATUS_BLOCKED,
}

# run.py BLOCKING_PLAN_BODY_GATES 와 같아야 한다. next_phase 는 run 을
# 가져오지 않는다 — wizard 가 둘 다 import 해서 순환이 생긴다.
_BLOCKING_PLAN_GATES = frozenset(
    {"blocked-by-disagreement", "aborted-non-result"}
)

# final-verification 의 routing enum 중 phase 이름이 아니라 phase 에 붙은 범위
# 한정자인 값 → 실제로 실행할 phase. `release-handoff(stage-group)` 은 넘길 stage
# 묶음을 좁힌다는 뜻이지 다른 phase 가 아니다. 범위는 위저드의 handoff_stage_pick
# 이 따로 묻는다. 토큰을 그대로 phase 에 두면 `autofill_task_type` 이 그 문자열을
# 셸에 TASK_TYPE 으로 건네는데, 그런 task-type 은 존재하지 않는다.
_FINAL_VERIFICATION_PHASE_ALIASES = {
    "release-handoff(stage-group)": "release-handoff",
}


def make(
    phase: str = "", status: str = STATUS_PENDING, rationale: str = ""
) -> dict[str, str]:
    """포인터 구조체 하나를 만든다. status 가 허용 집합 밖이면 거부한다."""
    if status not in POINTER_STATUSES:
        raise ValueError(f"unknown next-phase status: {status!r}")
    return {"phase": phase, "status": status, "rationale": rationale}


def is_pointer(value: Any) -> bool:
    """value 가 유효한 포인터 구조체인지."""
    return (
        isinstance(value, Mapping)
        and isinstance(value.get("phase"), str)
        and value.get("status") in POINTER_STATUSES
        and isinstance(value.get("rationale"), str)
    )


def promote(value: Any) -> dict[str, str]:
    """구형 문자열·None 을 포인터로 올린다.

    이미 포인터인 입력은 같은 값을 담은 새 dict 로 돌려준다 — 입력을 그대로
    돌려주지 않는다. 호출부가 반환값을 그 자리에서 변형해도 manifest 원본이
    오염되지 않아야 하기 때문이다.

    pre-v1 무호환 원칙의 의도적 예외다. 근거는 업그레이드를 가로지르는
    진행 중 태스크가 영구히 막히는 기존 사례 — ADR-0004 Consequences.
    """
    if is_pointer(value):
        return {
            "phase": value["phase"],
            "status": value["status"],
            "rationale": value["rationale"],
        }
    if not isinstance(value, str):
        return make()
    text = value.strip()
    if text in _LEGACY_SENTINELS:
        return make(status=_LEGACY_SENTINELS[text])
    return make(phase=text, status=STATUS_READY)


def autofill_task_type(manifest: Mapping[str, Any]) -> str:
    """매니페스트의 포인터에서 바로 실행 가능한 task-type 을 뽑는다.

    `ready` 가 아니면 빈 문자열이다. 셸 진입점의 autofill 이 이 함수를 쓴다.
    """
    workflow = manifest.get("workflow")
    if not isinstance(workflow, Mapping):
        return ""
    pointer = promote(workflow.get("nextRecommendedPhase"))
    return pointer["phase"] if pointer["status"] == STATUS_READY else ""


def project(report_data: Mapping[str, Any]) -> dict[str, str]:
    """리포트 data.json 하나를 포인터로 투영한다.

    라우팅 판단의 옳고 그름은 다루지 않는다. 리포트가 이미 내린 판단을
    옮기기만 한다. 규칙표는 이 프로젝트의 구현 계획 문서에 있다.
    """
    header = report_data.get("header")
    task_type = ""
    if isinstance(header, Mapping):
        task_type = str(header.get("taskType") or "")

    if task_type in _TERMINAL_TASK_TYPES:
        return make(status=STATUS_TERMINAL)
    if task_type in _SIDETRACK_TASK_TYPES:
        return make(status=STATUS_PENDING)

    if task_type == "requirements-discovery":
        return _from_nested_routing(report_data, "requirementsDiscovery")
    if task_type == "error-analysis":
        return _from_nested_routing(report_data, "errorAnalysis")
    if task_type == "implementation-option-selection":
        return _from_option_selection(report_data)
    if task_type == "implementation-planning":
        return _from_planning(report_data)
    if task_type == "implementation":
        return _from_target(report_data, "implementation")
    if task_type == "final-verification":
        return _from_final_verification(report_data)
    return make(status=STATUS_PENDING)


def _block(report_data: Mapping[str, Any], key: str) -> Mapping[str, Any]:
    block = report_data.get(key)
    return block if isinstance(block, Mapping) else {}


def _from_nested(
    report_data: Mapping[str, Any],
    block_key: str,
    routing_key: str,
    target_key: str,
) -> dict[str, str]:
    """블록 → 라우팅 → 대상 phase 로 두 단 내려가 읽는 공용 판독부.

    대상이 문자열이 아니면 결측과 똑같이 취급한다. 문자열이 아닌 값을 str() 로
    강제하면 dict repr 같은 것이 phase 이름 자리에 들어앉고, status 는 ready 가
    되어 소비자에게 "지금 시작 가능한 phase" 로 읽힌다. 결측은 pending 에서
    무해하게 멈추지만 이쪽은 존재하지 않는 phase 로 소비자를 보낸다.
    """
    routing = _block(report_data, block_key).get(routing_key)
    target = ""
    if isinstance(routing, Mapping):
        raw = routing.get(target_key)
        target = raw.strip() if isinstance(raw, str) else ""
    if not target:
        return make(status=STATUS_PENDING)
    return make(phase=target, status=STATUS_READY)


def _from_nested_routing(report_data: Mapping[str, Any], key: str) -> dict[str, str]:
    return _from_nested(report_data, key, "routing", "nextTaskType")


def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
    # 이 phase 의 routing 은 문자열 enum 이다. 이웃 두 phase 가 쓰는 중첩 dict 가
    # 잘못 들어오면 결측과 똑같이 취급한다 — str() 로 강제하면 dict repr 이 phase
    # 이름이 되어 ready 로 나간다.
    raw = _block(report_data, "implementationOptionSelection").get("routing")
    routing = raw.strip() if isinstance(raw, str) else ""
    if routing in _OPTION_SELECTION_NON_PHASE:
        return make(status=_OPTION_SELECTION_NON_PHASE[routing])
    if not routing:
        return make(status=STATUS_PENDING)
    return make(phase=routing, status=STATUS_READY)


def _unresolved_approval_ids(report_data: Mapping[str, Any]) -> list[str]:
    return progress_blocking_ids(
        report_data.get("clarificationItems"),
        APPROVAL_BLOCKS,
        report_data=report_data,
    )


def _planning_approval_block_reason(
    report_data: Mapping[str, Any], planning: Mapping[str, Any]
) -> str:
    """plan-ready 인데 승인할 수 없으면 근거, 아니면 빈 문자열.

    자문 게이트(`passed-with-dissent`)와 재현 실패 `has-dissent` 는 여기 안
    들어온다. 차단은 `aborted-non-result` 와, 사용자가 아직 진행 처분을
    고르지 않았고 이 런 원장에도 반영되지 않은 `Blocks=approval` 행이다.
    `blocked-by-disagreement` 는 그 행들이 전부 `accept-risk` / `select` /
    `answer` 이거나 원장이 되돌림 답을 반영했으면 증거가 된 뒤라
    포인터를 막지 않는다.
    """
    ids = _unresolved_approval_ids(report_data)
    if ids:
        listed = ", ".join(ids)
        return (
            f"{listed} 가 Blocks=approval 로 열려 승인할 수 없습니다. "
            "okstra-user-response 로 답한 뒤 그 답을 가지고 계획 단계를 "
            "재개하세요. 구현을 시작하거나, 답을 쓰기 전에 계획 단계를 "
            "다시 돌리지 마세요."
        )
    verification = planning.get("planBodyVerification")
    gate = ""
    if isinstance(verification, Mapping):
        gate = str(verification.get("gateResult") or "").strip().lower()
    if gate == "aborted-non-result":
        return (
            f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
            "구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
        )
    if gate == "blocked-by-disagreement":
        approval_rows = [
            row
            for row in (report_data.get("clarificationItems") or [])
            if isinstance(row, Mapping)
            and str(row.get("blocks") or "").strip().lower() in APPROVAL_BLOCKS
        ]
        if not approval_rows:
            return (
                f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
                "구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
            )
    return ""


def _from_planning(report_data: Mapping[str, Any]) -> dict[str, str]:
    planning = _block(report_data, "implementationPlanning")
    outcome = str(planning.get("outcome") or "")
    if outcome == "direction-invalidated":
        return make(phase="implementation-option-selection", status=STATUS_READY)
    if outcome != "plan-ready":
        return make(status=STATUS_PENDING)
    blocked_reason = _planning_approval_block_reason(report_data, planning)
    if blocked_reason:
        return make(status=STATUS_BLOCKED, rationale=blocked_reason)
    return make(phase="implementation", status=STATUS_READY)


def _from_target(report_data: Mapping[str, Any], key: str) -> dict[str, str]:
    return _from_nested(report_data, key, "routingRecommendation", "target")


def _from_final_verification(report_data: Mapping[str, Any]) -> dict[str, str]:
    pointer = _from_target(report_data, "finalVerification")
    if pointer["phase"] == "done":
        return make(status=STATUS_TERMINAL)
    if pointer["phase"] in _FINAL_VERIFICATION_PHASE_ALIASES:
        return make(
            phase=_FINAL_VERIFICATION_PHASE_ALIASES[pointer["phase"]],
            status=pointer["status"],
            rationale=pointer["rationale"],
        )
    return pointer
