"""위저드 공개 API — `init_state`, `next_prompt`, `submit`, 진행률·시뮬레이션·호스트 상호작용."""
from __future__ import annotations

import copy
import json
import math
from typing import Any, Optional

from okstra_ctl.application.advance_wizard import plan_prompt
from okstra_ctl.domain.host import HostSessionContext
from okstra_ctl.domain.wizard.interaction import (
    InteractionPlan,
    WizardAnswerError,
    normalize_planned_answer,
)
from okstra_ctl.registry.host_registry import default_host_registry
from okstra_ctl.run import PrepareError

from .ids import (
    GROUP_LABELS,
    GROUP_MAX_TABS,
    PICK_TYPE_CUSTOM,
    PROMPT_GROUPS,
    S_ABORTED,
    S_CONFIRM,
    S_DEFAULTS_OR_CUSTOM,
    S_DESIGN_PREP_CONFIRM,
    S_DESIGN_PREP_OVERRIDES,
    S_DONE,
    S_TASK_PICK,
    S_REPORT_LANGUAGE,
    TASK_PICK_NEW_TOKEN,
    _STEP_TO_GROUP,
)
from .state import Prompt, WizardError, WizardState, _is_role_selection_step
from .prompts import _domain_prompt
from .picker_navigation import (
    is_split_picker,
    merge_split_picker_answer,
    present_picker,
    split_picker,
)
from .roles import _submit_role_prompt, next_role_prompt
from .steps_identity import _submit_task_pick
from .steps_analysis import _advance_design_prep_item
from .registry import STEPS, STEP_BY_ID


# ---- Public API ---------------------------------------------------------

def init_state(
    *, workspace_root: str, project_root: str, project_id: str,
    host_runtime: str = "claude-code",
    host_entry_mode: str = "current-session",
    available_functions: Optional[list[str]] = None,
    preferred_task_key: str = "",
) -> WizardState:
    """Bootstrap a new wizard state."""
    return WizardState(
        execution_identity_version=2,
        workspace_root=workspace_root,
        project_root=project_root,
        project_id=project_id,
        preferred_task_key=preferred_task_key,
        host_runtime=host_runtime,
        host_entry_mode=host_entry_mode,
        available_functions=list(available_functions or []),
    )


def _auto_start_new_task_when_it_is_the_only_choice(state: WizardState) -> None:
    """미완료 task 가 없으면 중복된 새 작업 선택 화면을 건너뛴다."""
    prompt = next_prompt(state)
    if (
        prompt.step != S_TASK_PICK
        or [option.value for option in prompt.options] != [TASK_PICK_NEW_TOKEN]
    ):
        return
    _submit_task_pick(state, TASK_PICK_NEW_TOKEN)
    state.answered.append(S_TASK_PICK)


def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
    """그룹의 적용가능·미답변 픽 멤버를 최대 GROUP_MAX_TABS 개 모은다.

    멤버가 1개뿐이면 멀티탭 UI가 불필요하므로 그 멤버의 평범한 픽을 반환한다.
    호출부(next_prompt)는 적용 가능한 멤버가 최소 1개일 때만 진입하므로 빈 그룹은
    도달 불가다.
    """
    members: list[Prompt] = []
    for sid in PROMPT_GROUPS[group_id]:
        step = STEP_BY_ID[sid]
        if sid in state.answered and not step.repeatable:
            continue
        if not step.applies(state):
            continue
        members.append(step.build(state))
        if len(members) >= GROUP_MAX_TABS:
            break
    assert members, f"group {group_id!r} reached with no applicable members"
    if len(members) == 1:
        return members[0]
    return Prompt(step=group_id, kind="pick_group",
                  label=GROUP_LABELS[group_id], questions=members)


def _is_lone_free_input_pick(prompt: Prompt) -> bool:
    """고를 것이 "직접 입력" 하나뿐인 픽인가.

    후보가 0건이면 picker 는 탈출구 한 줄만 남는다. 그 화면은 질문이 아니라
    빈 목록이고, 호스트 선택기는 옵션 2개 미만을 받지 않아(`nativeLimits`
    `minOptions`) 번호 1개짜리 목록으로 내려간다 — 사용자는 고를 것이 없는
    목록에서 1 을 치고 나서야 값을 입력한다. 그런 화면은 건너뛰고 입력 단계로
    바로 간다.
    """
    return (
        prompt.kind == "pick"
        and len(prompt.options) == 1
        and prompt.options[0].value == PICK_TYPE_CUSTOM
    )


def _take_free_input_branch(state: WizardState, prompt: Prompt) -> None:
    """빈 picker 를 사용자 대신 "직접 입력" 으로 답한다 (echo 는 없다)."""
    STEP_BY_ID[prompt.step].submit(state, PICK_TYPE_CUSTOM)
    if prompt.step not in state.answered:
        state.answered.append(prompt.step)


def next_prompt(state: WizardState) -> Prompt:
    # 빈 picker 를 건너뛰면 그 자리에 다음 화면이 온다. 그 다음 화면이 또 빈
    # picker 일 수 있으므로 반복하되, 자동 전진은 step 당 한 번뿐이라 STEPS
    # 길이로 상한을 둔다.
    for _ in range(len(STEPS)):
        prompt = _next_prompt_screen(state)
        if not _is_lone_free_input_pick(prompt):
            return _native_picker_screen(state, prompt)
        _take_free_input_branch(state, prompt)
    return _next_prompt_screen(state)


def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
    """호스트 네이티브 선택기 한도에 맞춘 화면.

    한도를 넘는 픽은 체크박스든 단일 선택이든 네이티브 질문 묶음에 실리는
    크기(claude-code 4×4=16)까지 같은 화면의 체크박스 질문 여러 개로 자른다
    (`split_picker`) — 사용자는 탭을 옮겨 다니며 한 번에 답한다. 단일 선택을
    쪽으로 나누던 종전 화면은 후보 하나를 고르는 데 "다음 선택지" 를 누를
    때마다 턴이 하나씩 들었다(실측 2026-09-09, 후보 13개인 critic 화면).

    묶음에 못 실리면(옵션 16개 초과, 라벨 중복, 질문 묶음이 없는 세션)
    `CapabilityInteractionPort.plan` 이 단일 선택은 `numbered-single`,
    체크박스는 `numbered-multi` 로 내려 전체 목록을 한 번에 보인다.
    """
    if "native_single_select" not in state.available_functions:
        return prompt
    if prompt.kind == "pick_group":
        if _interaction_plan(state, prompt).kind == "native-group":
            return prompt
        prompt = prompt.questions[0]
    port = default_host_registry().resolve(state.host_runtime).interaction()
    split = split_picker(
        prompt,
        max_options=port.native_option_limit,
        max_questions=port.native_question_limit,
    )
    if split is not prompt and _interaction_plan(state, split).kind == "native-group":
        return split
    if prompt.multi:
        return prompt
    return present_picker(prompt)


def _next_prompt_screen(state: WizardState) -> Prompt:
    if state.aborted:
        return Prompt(step=S_ABORTED, kind="aborted")
    if state.confirmed:
        return Prompt(step=S_DONE, kind="done")
    for step in STEPS:
        if step.id == S_DEFAULTS_OR_CUSTOM:
            role_prompt = next_role_prompt(state)
            if role_prompt is not None:
                return role_prompt
        if step.id in state.answered and not step.repeatable:
            continue
        if step.applies(state):
            group_id = _STEP_TO_GROUP.get(step.id)
            if group_id is not None:
                return _build_group_prompt(state, group_id)
            return step.build(state)
    return Prompt(step=S_DONE, kind="done")


def _passed_screens(state: WizardState) -> int:
    """이미 답한 화면 수. pick_group 멤버는 GROUP_MAX_TABS 묶음당 1화면으로 환산."""
    count = 0
    group_hits: dict[str, int] = {}
    for sid in state.answered:
        gid = _STEP_TO_GROUP.get(sid)
        if gid is None:
            count += 1
        else:
            group_hits[gid] = group_hits.get(gid, 0) + 1
    for hits in group_hits.values():
        count += math.ceil(hits / GROUP_MAX_TABS)
    return count


def _sim_answer(prompt: Prompt) -> str:
    """분모 추정 시뮬레이션의 기본답: pick 은 추천(체크박스는 추천 집합, 없으면
    첫 옵션), text 는 빈 값."""
    if prompt.kind == "pick" and prompt.options:
        if prompt.multi:
            flagged = [option.value for option in prompt.options if option.recommended]
            if flagged:
                return ",".join(flagged)
        return prompt.options[0].value
    return ""


def _sim_advance(state: WizardState, prompt: Prompt) -> None:
    """기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
    _submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
    if prompt.step == S_REPORT_LANGUAGE:
        state.answered.append(prompt.step)
        return
    if is_split_picker(prompt):
        # 조각 질문의 step 은 등록된 step 이 아니다 — 잘리지 않은 원본으로 낸다.
        prompt = _next_prompt_screen(state)
    try:
        if _is_role_selection_step(prompt.step):
            # 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수 있다 — 기본답은
            # 잘리지 않은 원본(추천 집합)에서 낸다.
            original = next_role_prompt(state) or prompt
            _submit_role_prompt(state, original, _sim_answer(original))
            if prompt.step not in state.answered:
                state.answered.append(prompt.step)
            if prompt.step not in state.role_selection_order:
                state.role_selection_order.append(prompt.step)
            return
        if prompt.kind == "pick_group":
            for q in prompt.questions:
                STEP_BY_ID[q.step].submit(state, _sim_answer(q))
            for q in prompt.questions:
                if q.step not in state.answered:
                    state.answered.append(q.step)
            return
        if prompt.step == S_DESIGN_PREP_CONFIRM:
            _advance_design_prep_item(state)
            if prompt.step not in state.answered:
                state.answered.append(prompt.step)
            return
        if prompt.step == S_DESIGN_PREP_OVERRIDES:
            if state.design_prep_decision == "modify-draft":
                state.design_prep_overrides_json = "{}"
            else:
                state.design_prep_notes = "simulation"
            if prompt.step not in state.answered:
                state.answered.append(prompt.step)
            return
        STEP_BY_ID[prompt.step].submit(state, _sim_answer(prompt))
        if prompt.step not in state.answered:
            state.answered.append(prompt.step)
    except (WizardError, PrepareError):
        # 기본답이 거부되는 두 경우만 전진 처리한다 — 입력 검증이 막는 드문 text
        # 분기(WizardError), 그리고 답 자체는 유효하나 대상의 도메인 상태가 그
        # 경로를 막는 경우(PrepareError). 후자가 빠져 있어 승인 게이트가
        # `blocked-by-disagreement` 인 계획이 최신 태스크이면 위저드가 첫 화면조차
        # 내지 못하고 죽었다: 시뮬레이터가 기본 경로를 따라가다 그 계획을 고르고,
        # 진행률 라벨 하나 때문에 run 전체가 시작 불가가 됐다. 시뮬레이션은 분모
        # 추정이므로 막힌 경로를 만나면 그 화면을 지났다고 치고 계속 세면 된다.
        # KeyError/AttributeError 등 실제 버그는 여전히 삼키지 않고 그대로
        # 전파시켜, progress 라벨이 그럴듯하게 틀리는 대신 시끄럽게 실패한다.
        members = prompt.questions if prompt.kind == "pick_group" else [prompt]
        for p in members:
            if p.step not in state.answered:
                state.answered.append(p.step)


def _remaining_screens(state: WizardState) -> int:
    """현재 화면부터 confirm(=done 직전)까지 남은 화면 수를 시뮬레이션으로 센다."""
    sim = copy.deepcopy(state)
    screens = 0
    for _ in range(len(STEPS) * 2 + 5):  # Edit 루프 등에 대한 상한 가드
        try:
            prompt = next_prompt(sim)
        except Exception:
            break
        if prompt.kind in ("done", "aborted"):
            break
        screens += 1
        _sim_advance(sim, prompt)
        if prompt.step == S_CONFIRM:  # confirm 이후는 done — Edit 는 가정하지 않는다
            break
    return screens


def _screen_progress(state: WizardState) -> dict[str, Any]:
    passed = _passed_screens(state)
    total = passed + _remaining_screens(state)
    index = passed + 1
    remaining = max(0, total - index)
    suffix = "마지막 단계" if remaining == 0 else f"앞으로 {remaining} 스텝 남음"
    return {
        "index": index, "total": total, "remaining": remaining,
        "label": f"Step {index}/{total} · {suffix}",
    }


def _interaction_plan(state: WizardState, prompt: Prompt) -> InteractionPlan:
    context = HostSessionContext(
        host_id=state.host_runtime,
        entry_mode=state.host_entry_mode,
        available_functions=frozenset(state.available_functions),
        interaction_surface="terminal",
    )
    adapter = default_host_registry().resolve(state.host_runtime)
    return plan_prompt(_domain_prompt(prompt), context, adapter.interaction()).interaction


def _interaction_payload(plan: InteractionPlan) -> dict[str, Any]:
    return {
        "kind": plan.kind,
        "answerProtocol": {
            "kind": plan.answer_protocol.kind,
            "multi": plan.answer_protocol.multi,
        },
    }


def _normalize_interaction_answer(
    state: WizardState,
    prompt: Prompt,
    plan: InteractionPlan,
    value: str,
) -> str:
    try:
        return normalize_planned_answer(
            _domain_prompt(prompt),
            plan.answer_protocol,
            value,
        )
    except WizardAnswerError as exc:
        is_legacy_raw_answer = (
            not state.available_functions
            and "not an option" in str(exc)
            and not (value or "").strip().isdecimal()
        )
        if is_legacy_raw_answer:
            return value
        raise WizardError(str(exc)) from exc


def prompt_payload(state: WizardState, prompt: Prompt) -> dict[str, Any]:
    """Prompt JSON 에 진행 카운터를 덧붙인다. done/aborted 는 progress 를 생략."""
    out = prompt.to_json()
    plan = _interaction_plan(state, prompt)
    out["interaction"] = _interaction_payload(plan)
    if plan.kind in {"numbered-single", "numbered-multi"}:
        out["presentation"] = "numbered-text"
    if prompt.kind not in ("done", "aborted"):
        out["progress"] = _screen_progress(state)
    return out


def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, Any]:
    """pick_group 답(JSON 객체)을 각 멤버 submit() 으로 라우팅한다.

    멤버 submit 이 WizardError 를 던지면 그대로 전파되어 같은 그룹을 재-프롬프트한다.
    answered 마킹은 모든 멤버 submit 이 통과한 뒤에만 일괄 수행한다(answered 단위의
    전부-아니면-전무). 개별 멤버가 변경한 state 필드는 롤백하지 않지만, 재-프롬프트 시
    같은 그룹이 다시 나와 사용자 입력으로 덮어쓰므로 무해하다.
    """
    try:
        answers = json.loads(value or "{}")
    except json.JSONDecodeError as exc:
        raise WizardError(f"pick_group answer must be a JSON object: {exc}")
    if not isinstance(answers, dict):
        raise WizardError("pick_group answer must be a JSON object")
    echoes: list[str] = []
    for q in prompt.questions:
        echo = STEP_BY_ID[q.step].submit(state, str(answers.get(q.step, "") or ""))
        if echo:
            echoes.append(echo)
    for q in prompt.questions:
        if q.step not in state.answered:
            state.answered.append(q.step)
    nxt = next_prompt(state)
    return {"echo": "; ".join(echoes), "next": prompt_payload(state, nxt)}


def submit(state: WizardState, value: str) -> dict[str, Any]:
    """Validate the answer for the *currently active* step and advance.

    Returns {"echo": "...", "next": <Prompt JSON>}. Raises WizardError on
    validation failure (caller may re-prompt).
    """
    confirmation_was_displayed = bool(state.confirmation_prompt)
    prompt = next_prompt(state)
    if prompt.kind in ("done", "aborted"):
        return {"echo": "", "next": prompt_payload(state, prompt)}
    plan = _interaction_plan(state, prompt)
    value = _normalize_interaction_answer(state, prompt, plan, value)
    if prompt.step == S_CONFIRM and value == "proceed" and not confirmation_was_displayed:
        return {"echo": "", "next": prompt_payload(state, prompt)}
    if is_split_picker(prompt):
        # 질문 묶음으로 잘린 픽 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의 제출
        # 경로로 보낸다. 원본의 선택지로 검증한다.
        value = merge_split_picker_answer(prompt, value)
        prompt = _next_prompt_screen(state)
    elif prompt.kind == "pick_group":
        return _submit_group(state, prompt, value)
    if _is_role_selection_step(prompt.step):
        # 화면의 중복 라벨을 구분했어도 답은 원본 선택지의 값으로 검증한다.
        original = next_role_prompt(state)
        if original is not None and original.step == prompt.step:
            prompt = original
        echo = _submit_role_prompt(state, prompt, value or "")
        if prompt.step not in state.answered:
            state.answered.append(prompt.step)
        if prompt.step not in state.role_selection_order:
            state.role_selection_order.append(prompt.step)
        nxt = next_prompt(state)
        return {"echo": echo, "next": prompt_payload(state, nxt)}
    step = STEP_BY_ID[prompt.step]
    echo = step.submit(state, value or "")
    if prompt.step not in state.answered:
        state.answered.append(prompt.step)
    nxt = next_prompt(state)
    return {"echo": echo or "", "next": prompt_payload(state, nxt)}
