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

포인터는 `{phase, status, rationale}` 구조체다. 값은 리포트의 라우팅 필드에서
투영된다 — 리드가 그 구조체를 직접 쓰는 표면은 없고, 매니페스트에 있던 값과
대조하지도 않는다. 리드가 통제하는 것은 리포트의 라우팅 필드뿐이다.
이 모듈은 세 가지만 소유한다 — 구조체 판별, 구형 문자열 승격, 리포트 투영.
다음 phase 를 계산하는 다른 알고리즘을 다시 만들지 않는다.
"""
from __future__ import annotations

from typing import Any, Mapping

from okstra_ctl.clarification_items import (
    APPROVAL_BLOCKS,
    progress_blocking_ids,
)


# 포인터 값 자체(구조체·판별·승격)는 하위 계층이 소유한다. 읽기측 상태 접근자
# okstra_project.state 가 매니페스트를 돌려줄 때마다 승격을 해야 하는데, 그 값
# 타입이 여기 있으면 okstra_ctl 패키지 __init__ 을 통해 순환이 된다. 근거는
# okstra_project/phase_pointer.py 의 모듈 도크스트링.
#
# 이름을 그대로 재노출하므로 next_phase.make / .promote / .STATUS_* 를 쓰는
# 기존 호출부는 바뀌지 않는다.
from okstra_project.phase_pointer import (  # noqa: F401 — 의도적 재노출
    LEGACY_SENTINELS as _LEGACY_SENTINELS,
    POINTER_STATUSES,
    STATUS_BLOCKED,
    STATUS_PENDING,
    STATUS_READY,
    STATUS_TERMINAL,
    is_pointer,
    make,
    promote,
)

# 라우팅 필드가 없어 항상 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",
        "technical-verification",
        "implementation-option-selection",
        "implementation-planning",
        "implementation",
        "final-verification",
    }
)

# implementation-option-selection 의 routing enum 중 phase 가 아닌 값.
# `pending-direction-selection` 과 `blocked` 는 `_from_option_selection` 이
# 명시 분기로 다룬다.
_OPTION_SELECTION_NON_PHASE = {
    "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 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:
        # release-handoff 의 라우팅 칸은 자유 문자열이다. phase 로 삼을 값은
        # 없지만(생애주기가 여기서 끝난다) 그 문장은 태스크가 왜 끝났는지
        # 말하므로 근거 자리에 싣는다.
        return make(
            status=STATUS_TERMINAL,
            rationale=_text(
                _block(report_data, "releaseHandoff").get("routingRecommendation")
            ),
        )
    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 == "technical-verification":
        return _from_nested_routing(report_data, "technicalVerification")
    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 _text(value: Any) -> str:
    """문자열이면 다듬어 돌려주고, 아니면 결측으로 취급한다."""
    return value.strip() if isinstance(value, str) 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 로 소비자를 보낸다.

    같은 라우팅 객체의 `rationale` 을 포인터에 함께 싣는다. 그 문장은 리드가
    "왜 다음이 이 phase 인가" 를 쓴 자리고, 스키마가 네 phase 전부에서 필수로
    요구한다. 여기서 안 실으면 포인터에 남는 것은 phase 이름 하나뿐이라, 다음
    작업을 추천받는 사람이 근거 없이 이름만 본다.

    대상이 없으면 근거도 버린다 — 목적지를 설명하는 문장이므로 목적지 없이
    남으면 무엇을 설명하는지 알 수 없다.
    """
    routing = _block(report_data, block_key).get(routing_key)
    target = ""
    rationale = ""
    if isinstance(routing, Mapping):
        target = _text(routing.get(target_key))
        rationale = _text(routing.get("rationale"))
    if not target:
        return make(status=STATUS_PENDING)
    return make(phase=target, status=STATUS_READY, rationale=rationale)


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


# 차단된 비교가 "그래서 뭘 하라는 것인가" 를 쓰는 칸. 스키마가 필수로 요구한다
# (`ImplementationOptionSelectionUserNarrative.required`).
_SELECTION_GUIDANCE_CAP = 400


def _selection_guidance(selection: Mapping[str, Any]) -> str:
    """`userNarrative.selectionGuidance` 의 첫 문단.

    전체는 여러 문단이고 포인터의 근거는 한 줄로 인용되는 자리다. 첫 문단이
    "이 run 에서 고를 것이 없다 / 무엇을 갖춰 다시 돌려라" 를 말한다.
    """
    narrative = selection.get("userNarrative")
    if not isinstance(narrative, Mapping):
        return ""
    guidance = narrative.get("selectionGuidance")
    if not isinstance(guidance, Mapping):
        return ""
    text = _text(guidance.get("text"))
    if not text:
        return ""
    first = text.split("\n", 1)[0].strip()
    if len(first) > _SELECTION_GUIDANCE_CAP:
        first = first[:_SELECTION_GUIDANCE_CAP].rstrip() + "…"
    return first


def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
    # 이 phase 의 routing 은 문자열 enum 이다. 이웃 두 phase 가 쓰는 중첩 dict 가
    # 잘못 들어오면 결측과 똑같이 취급한다 — str() 로 강제하면 dict repr 이 phase
    # 이름이 되어 ready 로 나간다.
    selection = _block(report_data, "implementationOptionSelection")
    raw = selection.get("routing")
    routing = raw.strip() if isinstance(raw, str) else ""
    if routing == "blocked":
        # 차단은 목적지가 없다. 근거까지 비우면 포인터에 남는 것이 "막혔다" 뿐이고,
        # 소비자에게 남는 유일한 행동은 같은 phase 를 그대로 다시 돌리는 것이다.
        # 그 재실행이 같은 이유로 또 차단되면 사용자는 이유를 한 번도 못 본 채
        # 같은 자리를 돈다. 이웃 분기들은 이미 근거를 싣는다 — `_from_nested` 는
        # 라우팅의 `rationale`, `_from_planning` 은 승인 차단 사유,
        # release-handoff 는 `routingRecommendation`.
        return make(status=STATUS_BLOCKED, rationale=_selection_guidance(selection))
    if routing == "pending-direction-selection":
        # 이 상태는 **성공**이다 — 비교가 끝났고, 방향은 계획 단계의 위저드가
        # 고르게 한다(`selected_direction_pick`). 그러니 다음 phase 는 지금 바로
        # 시작할 수 있는 `implementation-planning` 이고 status 는 `ready` 다.
        # 종전에는 phase 없는 `pending` 이었다: task 선택 화면이 `next: --
        # (pending)` 을 찍어 목적지를 지웠고, task-type 화면은 추천 없이 방금
        # 끝난 phase 의 재실행을 1번에 올렸다(실측 2026-09-09) — 근거 문장은
        # "다시 돌리지 마세요" 라고 말하는데 화면은 그 반대를 권한 셈이다.
        # 근거는 후보 id 를 이름으로 싣는다. 실측(dev-10341): 1회차가
        # IO-001·IO-002 를 내고 안내 없이 멈춰 같은 phase 가 세 번 더 돌았다.
        # 후보가 0건이면 고를 것이 없으므로 종전대로 phase 없는 pending 이다.
        rationale = _direction_selection_reason(selection)
        if not rationale:
            return make(status=STATUS_PENDING)
        return make(
            phase="implementation-planning", status=STATUS_READY, rationale=rationale
        )
    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 _direction_selection_reason(selection: Mapping[str, Any]) -> str:
    """비교가 끝난 run 이 사용자에게 남기는 다음 행동.

    후보 id 를 이름으로 말한다 — 고르라는 말만 있고 고를 것의 이름이 없으면
    사용자는 리포트를 열어 목록을 찾아야 한다.
    """
    options = selection.get("rankedOptions")
    ids = [
        _text(row.get("id"))
        for row in options
        if isinstance(row, Mapping) and _text(row.get("id"))
    ] if isinstance(options, list) else []
    if not ids:
        return ""
    listed = ", ".join(f"`{item}`" for item in ids)
    recommended = _text(selection.get("recommendedOptionId"))
    head = f"비교가 끝났습니다. 후보 {listed} 중 하나를 방향으로 고르세요"
    head += f" (권장 `{recommended}`)." if recommended else "."
    return (
        f"{head} 고르는 자리는 계획 단계의 위저드입니다 — `/okstra-run` 으로 "
        "`implementation-planning` 을 시작하면 이 후보들이 선택지로 나옵니다. "
        "이 phase 를 다시 돌리지 마세요: 비교는 이미 끝났고, 재실행은 같은 "
        "비교를 처음부터 다시 합니다."
    )


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)
    # 후보 비교 계획은 outcome 칸이 없다. 열린 승인 차단과 차단 게이트만
    # 구현을 막는다. 칸이 없다고 pending 으로 두면 종료 안내가 inspect 로 간다.
    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":
        # `done` 은 phase 가 아니라 생애주기 종료다. 근거는 그대로 남긴다 —
        # 태스크를 끝내도 되는 이유가 사용자에게 가장 필요한 문장이다.
        return make(status=STATUS_TERMINAL, rationale=pointer["rationale"])
    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
