"""역할 인스턴스 선택 루프 — `next_role_prompt` 와 역할 모델 픽의 검증·되감기.

역할 하나에 화면 하나다. 여러 인스턴스를 띄우는 역할(analyser·designer·planner·
verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인스턴스 수다 —
"몇 개를 띄울까" 를 따로 묻지 않는다(종전 `role-count:` 화면, 실측 2026-09-09
사용자 요청으로 제거). 허용 범위와 권장 수는 화면 문구가 말한다. 선택 역할
(`min = 0`, 예: critic)은 같은 화면에 "추가 안 함" 줄이 있다(종전 `role-add:`).
고정 단일 역할(`min = max = 1`, 예: report-writer·implementer)은 단일 선택 한 장이다.

모델 화면(`role-models:<role>`, `role-model:<role>:1`)은 실행 가능한 전체 후보를
한 번에 제공자별로 묶어 싣는다. 기본 후보(프로젝트 `modelDefaults`, 없으면
카탈로그 기본값)에서 권장 수만큼 추천하고 제공자 안에서는 기본 후보가 앞이다.
호스트 네이티브 선택기의 옵션 한도
(claude-code 4, codex 3, grok 15)를 넘으면 체크박스든 단일 선택이든 네이티브
질문 묶음에 실리는 크기(claude-code 4×4)까지는 같은 화면의 체크박스 질문 여러
개로 자르고(`picker_navigation.split_picker`), 그것도 넘으면 체크박스는
`CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 번호 목록이,
단일 선택은 쪽 나누기가 된다 — 기본 후보만 실은 짧은 화면과 "직접 선택" 이 여는
두 번째 화면으로 나누던 설계는 2026-09-09 사용자 요청으로 뺐다(후보 12개 중
3개만 보이고, 두 번째 화면은 쪽 나누기가 추천 불변식을 깨 열리지도 않았다).
"""
from __future__ import annotations

from pathlib import Path

from okstra_ctl.assignment_environment import load_assignment_context
from okstra_ctl.assignment_resolver import (
    AssignmentContext,
    AssignmentResolutionError,
    RoleInstance,
    resolve_assignments,
    resolve_model_assignment,
)
from okstra_ctl.domain.host import CurrentSessionModelAttestation, HostSessionContext
from okstra_ctl.registry.provider_registry import provider_display_order
from okstra_ctl.dispatch_state import detect_terminal_backend
from okstra_ctl.registry.host_registry import default_host_registry
from okstra_ctl.model_defaults import ModelDefaultScopes, default_candidates
from okstra_ctl.model_pool import ModelPool
from okstra_ctl.role_requirements import RoleProfile, RoleRequirement
from okstra_ctl.run import (
    PrepareError,
    _assignment_resolution_message,
    _model_default_scopes,
)

from .state import (
    Option,
    Prompt,
    WizardError,
    WizardState,
    _identity_ready,
    _is_role_selection_step,
    _load_role_profile_for_state,
    _role_selection_enabled,
)
from .prompts import _opt, _p

# 선택 역할의 "추가 안 함" 값. 모델 참조는 `<provider>/<model>` 꼴이라 겹치지 않는다.
ROLE_SKIP_TOKEN = "0"

_MODELS_PREFIX = "role-models:"
_SINGLE_PREFIX = "role-model:"


def _role_models_prompt_id(role: str) -> str:
    return f"{_MODELS_PREFIX}{role}"


def _role_model_prompt_id(role: str, ordinal: int) -> str:
    return f"{_SINGLE_PREFIX}{role}:{ordinal}"


def _step_role(step_id: str) -> str:
    """역할 선택 step id 가 가리키는 역할. 역할 선택 step 이 아니면 빈 문자열."""
    for prefix in (_MODELS_PREFIX, _SINGLE_PREFIX):
        if step_id.startswith(prefix):
            return step_id[len(prefix):].split(":", 1)[0]
    return ""


def _fixed_single(requirement: RoleRequirement) -> bool:
    """정확히 한 인스턴스만 두는 역할 — 단일 선택 화면이다."""
    return requirement.min_count == requirement.max_count == 1


def _selected_role_count(
    state: WizardState,
    requirement: RoleRequirement,
) -> int:
    if requirement.min_count == requirement.max_count:
        return requirement.min_count
    if requirement.role in state.role_counts:
        return state.role_counts[requirement.role]
    # min=0 선택 역할(예: critic)은 사용자가 열기 전까지 기본 0
    if requirement.min_count == 0:
        return 0
    return requirement.recommended_count


def _selectable_static_requirements(
    state: WizardState,
    profile: RoleProfile,
) -> tuple[RoleRequirement, ...]:
    return tuple(
        requirement
        for requirement in profile.roles
        if not requirement.dynamic
        and _selected_role_count(state, requirement) > 0
    )


def _static_requirements(profile: RoleProfile) -> tuple[RoleRequirement, ...]:
    return tuple(
        requirement for requirement in profile.roles
        if not requirement.dynamic and requirement.max_count > 0
    )


def _count_range_text(requirement: RoleRequirement, t: dict) -> str:
    """화면 문구의 허용 범위: "허용 2..5개, 권장 3개" 또는 "정확히 2개"."""
    ranges = t["labels"]
    if requirement.min_count == requirement.max_count:
        return ranges["exact"].format(count=requirement.max_count)
    return ranges["range"].format(
        minimum=requirement.min_count,
        maximum=requirement.max_count,
        recommended=requirement.recommended_count,
    )


def _role_models_texts(
    state: WizardState, prompt_key: str, requirement: RoleRequirement,
) -> dict:
    """`role_models` 문구. 허용 범위 문장은 `range` 키다."""
    probe = _p(state.workspace_root, prompt_key, role=requirement.role, range="")
    range_text = _count_range_text(requirement, probe)
    texts = _p(state.workspace_root, prompt_key, role=requirement.role, range=range_text)
    texts["range"] = range_text
    return texts


def _role_default_candidates(
    state: WizardState,
    role: str,
    pool: ModelPool,
    scopes: ModelDefaultScopes | None = None,
) -> tuple[str, ...]:
    try:
        selected_scopes = scopes or _model_default_scopes(Path(state.project_root))
        scoped = default_candidates(role, selected_scopes)
    except (PrepareError, ValueError) as exc:
        raise WizardError(str(exc)) from exc
    if scoped:
        for model_ref in scoped:
            availability = pool.availability(
                model_ref,
                role,
                state.host_runtime,
                "new-session",
            )
            if not availability.available:
                raise WizardError(
                    f"configured default model {model_ref!r} is unavailable for "
                    f"role {role!r}: {availability.reason}"
                )
        return tuple(scoped)
    return tuple(
        str(model.model_ref) for model in pool.default_candidates(role)
    )


def _available_role_models(
    state: WizardState,
    profile: RoleProfile,
    requirement: RoleRequirement,
    context: AssignmentContext,
    scopes: ModelDefaultScopes,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
    """(기본 후보, 실행 가능한 전체 후보). 전체 후보는 기본 후보를 앞에 둔다.

    실행 가능 여부는 prepare 와 같은 배정 스냅샷(`resolve_model_assignment`)으로
    판정한다 — 카탈로그에는 있지만 이 호스트·환경에서 못 띄우는 모델은 싣지
    않는다.
    """
    role = requirement.role
    executable = set(_executable_role_models(state, profile, requirement, context))
    defaults = tuple(
        model_ref
        for model_ref in _role_default_candidates(state, role, context.pool, scopes)
        if model_ref in executable
    )
    everything = tuple(dict.fromkeys([
        *defaults,
        *(
            str(model.model_ref) for model in context.pool.list(role=role)
            if str(model.model_ref) in executable
        ),
    ]))
    return defaults, everything


def _skip_option(requirement: RoleRequirement, t: dict, *, recommended: bool) -> Option:
    # 역할을 빼면 그 역할이 맡던 판정이 사라진다. 그 결과를 아는 역할만 경고를
    # 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다. 경고는 라벨이
    # 아니라 설명에 실린다: 체크박스 탭의 답은 고른 라벨을 `, ` 로 이어 붙인
    # 한 줄이라(claude-code relay), 쉼표가 든 라벨 하나가 그 화면 전체를
    # 네이티브 묶음에서 떨어뜨려 쪽 나누기로 되돌린다.
    warnings = t["options"].get("skip_warnings", {})
    warning = warnings.get(requirement.role, "") if isinstance(warnings, dict) else ""
    return _opt(
        ROLE_SKIP_TOKEN,
        t["options"]["skip"],
        warning,
        recommended=recommended,
    )


def _role_models_prompt(
    state: WizardState,
    profile: RoleProfile,
    requirement: RoleRequirement,
    context: AssignmentContext,
    scopes: ModelDefaultScopes,
) -> Prompt:
    """역할 하나의 모델 화면 — 실행 가능한 전체 후보를 제공자별로 묶는다.

    추천은 정렬 전 권장 수만큼의 후보다 — 프로젝트 `modelDefaults`(없으면 카탈로그
    기본값) 순서가 그 근거다. 권장이 0인 선택 역할은 "추가 안 함" 이 추천이다.
    """
    role = requirement.role
    pool = context.pool
    _defaults, everything = _available_role_models(
        state, profile, requirement, context, scopes,
    )
    if not everything:
        _validate_role_selection_feasibility(state, profile, context, scopes)
        raise WizardError(f"role {role!r} has no executable model candidates")
    t = _role_models_texts(state, "role_models", requirement)
    multi = requirement.max_count > 1
    # "고른 만큼 인스턴스를 띄웁니다" 는 체크박스 문구다 — 최대 1개인 역할(critic)은
    # 단일 선택이라 인스턴스가 1개로 고정이고, 문구가 그 사실을 말해야 한다
    # (실측 2026-09-09, critic 화면이 체크박스 문구를 달고 나왔다).
    label = t["label"] if multi else t["labels"]["single"].format(
        role=requirement.role, range=t["range"],
    )
    optional = requirement.min_count == 0
    skip_first = optional and requirement.recommended_count == 0
    options: list[Option] = []
    for index, model_ref in enumerate(everything):
        model = pool.resolve(model_ref)
        options.append(_opt(
            model_ref,
            t["options"]["model"].format(model_ref=model_ref, display=model.display_name),
            recommended=index < requirement.recommended_count,
        ))
    options.sort(key=lambda option: provider_display_order(option.value.split("/", 1)[0]))
    if skip_first:
        options.insert(0, _skip_option(requirement, t, recommended=True))
    if optional and not skip_first:
        options.append(_skip_option(requirement, t, recommended=False))
    return Prompt(
        step=_role_models_prompt_id(role),
        kind="pick",
        multi=multi,
        label=label,
        options=options,
        echo_template=t["echo_template"],
    )


def _single_model_options(
    state: WizardState,
    profile: RoleProfile,
    role: str,
    context: AssignmentContext,
    scopes: ModelDefaultScopes,
) -> list[Option]:
    """고정 단일 역할의 후보를 제공자별로 묶고 기존 추천을 유지한다."""
    pool = context.pool
    requirement = next(row for row in profile.roles if row.role == role)
    defaults, everything = _available_role_models(
        state, profile, requirement, context, scopes,
    )
    prompt = _p(state.workspace_root, "role_model", role=role)
    options: list[Option] = []
    for model_ref in everything:
        candidate_models = {
            selected_role: tuple(models)
            for selected_role, models in state.role_models.items()
        }
        candidate_models[role] = (model_ref,)
        if not _role_selection_can_complete(
            state, profile, context, candidate_models, scopes,
        ):
            continue
        model = pool.resolve(model_ref)
        options.append(_opt(
            model_ref,
            prompt["options"]["model"].format(
                model_ref=model_ref,
                display=model.display_name,
                default_suffix=(
                    prompt["options"].get("default_suffix", "")
                    if model_ref in defaults
                    else ""
                ),
            ),
            recommended=not options,
        ))
    if not options:
        _validate_role_selection_feasibility(state, profile, context, scopes)
        raise WizardError(f"role {role!r} has no executable model candidates")
    options.sort(key=lambda option: provider_display_order(option.value.split("/", 1)[0]))
    return options


def _model_prompt(
    state: WizardState,
    profile: RoleProfile,
    requirement: RoleRequirement,
    context: AssignmentContext,
    scopes: ModelDefaultScopes,
) -> Prompt:
    prompt = _p(state.workspace_root, "role_model", role=requirement.role)
    return Prompt(
        step=_role_model_prompt_id(requirement.role, 1),
        kind="pick",
        label=prompt["label"],
        options=_single_model_options(
            state, profile, requirement.role, context, scopes,
        ),
        echo_template=prompt["echo_template"],
    )


def _drop_role_selection(state: WizardState, roles: set[str]) -> None:
    """이 역할들의 선택값과 step 기록을 지운다."""
    for role in roles:
        state.role_models.pop(role, None)
        state.role_counts.pop(role, None)
    state.role_selection_order = [
        step_id for step_id in state.role_selection_order
        if _step_role(step_id) not in roles
    ]
    state.answered = [
        step_id for step_id in state.answered
        if not (_is_role_selection_step(step_id) and _step_role(step_id) in roles)
    ]


def _discard_invalid_previous_models(
    state: WizardState,
    profile: RoleProfile,
    requirements: tuple[RoleRequirement, ...],
    context: AssignmentContext,
    scopes: ModelDefaultScopes,
) -> None:
    """재개한 상태의 선택값이 지금의 배정 스냅샷에서 무효면 그 역할부터 되감는다.

    무효 판정은 역할 단위다 — 체크박스 한 장이 그 역할의 답이므로, 그 안의 모델
    하나가 빠졌어도 같은 화면을 다시 묻는다. 그 뒤 역할들은 앞 역할의 답에
    따라 후보가 달라지므로 함께 비운다.
    """
    selected_models = {
        role: tuple(models) for role, models in state.role_models.items()
    }
    if _role_selection_error(
        state, profile, context, selected_models, scopes,
    ) is None:
        return
    prefix: dict[str, tuple[str, ...]] = {}
    for index, requirement in enumerate(requirements):
        selected = state.role_models.get(requirement.role, [])
        if not selected:
            continue
        count = _selected_role_count(state, requirement)
        candidate = dict(prefix)
        candidate[requirement.role] = tuple(selected)
        if len(selected) > count or not _role_selection_can_complete(
            state, profile, context, candidate, scopes,
        ):
            _drop_role_selection(
                state, {later.role for later in requirements[index:]},
            )
            return
        prefix[requirement.role] = tuple(selected)


def _host_session_context(state: WizardState) -> HostSessionContext:
    native_provider = default_host_registry().resolve(
        state.host_runtime
    ).descriptor.native_provider_id
    return HostSessionContext(
        host_id=state.host_runtime,
        entry_mode=state.host_entry_mode,
        available_functions=frozenset(state.available_functions),
        interaction_surface="wizard",
        current_model=CurrentSessionModelAttestation.unknown(native_provider),
    )


def _role_selection_error(
    state: WizardState,
    profile: RoleProfile,
    context: AssignmentContext,
    role_models: dict[str, tuple[str, ...]],
    scopes: ModelDefaultScopes,
) -> str | None:
    try:
        resolve_assignments(
            profile=profile,
            role_counts=dict(state.role_counts),
            role_models=role_models,
            scopes=scopes,
            pool=context.pool,
            host=_host_session_context(state),
            environment=context.environment,
        )
    except AssignmentResolutionError as exc:
        return _assignment_resolution_message(exc)
    except (PrepareError, ValueError, RuntimeError) as exc:
        return str(exc)
    return None


def _executable_role_models(
    state: WizardState,
    profile: RoleProfile,
    requirement: RoleRequirement,
    context: AssignmentContext,
) -> tuple[str, ...]:
    host = _host_session_context(state)
    candidates: list[str] = []
    instance = RoleInstance(requirement.role, requirement.duty, 1)
    for model in context.pool.list(role=requirement.role):
        model_ref = str(model.model_ref)
        try:
            resolve_model_assignment(
                instance=instance,
                model_ref=model_ref,
                pool=context.pool,
                host=host,
                environment=context.environment,
            )
        except (AssignmentResolutionError, ValueError, RuntimeError):
            continue
        candidates.append(model_ref)
    return tuple(candidates)


def _completed_role_models(
    state: WizardState,
    profile: RoleProfile,
    context: AssignmentContext,
    role_models: dict[str, tuple[str, ...]],
) -> dict[str, tuple[str, ...]] | None:
    completed = {role: list(models) for role, models in role_models.items()}
    for requirement in _selectable_static_requirements(state, profile):
        count = _selected_role_count(state, requirement)
        selected = completed.setdefault(requirement.role, [])
        if len(selected) > count:
            return None
        candidates = _executable_role_models(state, profile, requirement, context)
        if not candidates or any(model not in candidates for model in selected):
            return None
        # 같은 역할 패널의 model_ref 는 서로 달라야 한다.
        if len(set(selected)) < len(selected):
            return None
        used = set(selected)
        remaining = [model for model in candidates if model not in used]
        if len(remaining) < count - len(selected):
            return None
        while len(selected) < count:
            model_ref = next(
                model for model in candidates if model not in used
            )
            selected.append(model_ref)
            used.add(model_ref)
    return {role: tuple(models) for role, models in completed.items()}


def _role_selection_can_complete(
    state: WizardState,
    profile: RoleProfile,
    context: AssignmentContext,
    role_models: dict[str, tuple[str, ...]],
    scopes: ModelDefaultScopes,
) -> bool:
    completed = _completed_role_models(state, profile, context, role_models)
    return completed is not None and _role_selection_error(
        state, profile, context, completed, scopes,
    ) is None


def _validate_role_selection_feasibility(
    state: WizardState,
    profile: RoleProfile,
    context: AssignmentContext | None = None,
    scopes: ModelDefaultScopes | None = None,
) -> None:
    assignment_context = context or load_assignment_context(
        host_runtime=state.host_runtime,
        terminal_backend=detect_terminal_backend(),
    )
    selected_scopes = scopes or _model_default_scopes(Path(state.project_root))
    error = _role_selection_error(
        state,
        profile,
        assignment_context,
        {
            role: tuple(models) for role, models in state.role_models.items()
        },
        selected_scopes,
    )
    if error is not None:
        raise WizardError(error)


def _load_context(state: WizardState) -> tuple[AssignmentContext, ModelDefaultScopes]:
    try:
        scopes = _model_default_scopes(Path(state.project_root))
    except (PrepareError, ValueError) as exc:
        raise WizardError(str(exc)) from exc
    context = load_assignment_context(
        host_runtime=state.host_runtime,
        terminal_backend=detect_terminal_backend(),
    )
    return context, scopes


def next_role_prompt(state: WizardState) -> Prompt | None:
    """프로필 순서대로 역할마다 화면 하나 — 답이 없는 첫 역할의 화면을 낸다."""
    if not _role_selection_enabled(state) or not _identity_ready(state):
        return None
    state.use_defaults = False
    profile = _load_role_profile_for_state(state)
    requirements = _static_requirements(profile)
    context, scopes = _load_context(state)
    _discard_invalid_previous_models(state, profile, requirements, context, scopes)
    for requirement in requirements:
        role = requirement.role
        selected = state.role_models.get(role, [])
        if _fixed_single(requirement):
            if not selected:
                return _model_prompt(state, profile, requirement, context, scopes)
            continue
        if role not in state.role_counts:
            return _role_models_prompt(state, profile, requirement, context, scopes)
        count = state.role_counts[role]
        if count != len(selected) or not (
            (0 if requirement.min_count == 0 else requirement.min_count)
            <= count <= requirement.max_count
        ):
            # 재개한 상태의 수와 모델이 어긋난다 — 그 역할부터 다시 묻는다.
            _drop_role_selection(
                state,
                {later.role for later in requirements[requirements.index(requirement):]},
            )
            return _role_models_prompt(state, profile, requirement, context, scopes)
    _validate_role_selection_feasibility(state, profile, context, scopes)
    return None


def _requirement_for(profile: RoleProfile, role: str) -> RoleRequirement:
    requirement = next((row for row in profile.roles if row.role == role), None)
    if requirement is None or requirement.dynamic or requirement.max_count == 0:
        raise WizardError(f"role {role!r} does not accept a model selection")
    return requirement


def _submit_role_models(
    state: WizardState,
    profile: RoleProfile,
    requirement: RoleRequirement,
    prompt: Prompt,
    value: str,
) -> str:
    """체크박스(또는 선택 역할 단일 픽)의 답을 그 역할의 수와 모델로 확정한다."""
    role = requirement.role
    t = _role_models_texts(state, "role_models", requirement)
    range_text = t["range"]
    # 같은 값이 두 번 오면(번호 목록에서 `1,1`) 한 번으로 센다 — 거절할 일이 아니다.
    picked = list(dict.fromkeys(
        item.strip() for item in (value or "").split(",") if item.strip()
    ))
    allowed = {option.value for option in prompt.options}
    unknown = [item for item in picked if item not in allowed]
    if unknown:
        raise WizardError(t["errors"]["unknown_option"].format(values=", ".join(unknown)))
    models = [item for item in picked if item != ROLE_SKIP_TOKEN]
    # "추가 안 함" 과 모델을 같이 고르면 모델을 고른 것이다.
    if not models:
        if ROLE_SKIP_TOKEN not in picked:
            raise WizardError(t["errors"]["min_one_required"].format(range=range_text))
        previous = (state.role_counts.get(role), state.role_models.get(role))
        state.role_counts[role] = 0
        state.role_models.pop(role, None)
        return t["echo_variants"]["skipped"]
    if not requirement.min_count <= len(models) <= requirement.max_count:
        raise WizardError(t["errors"]["count_out_of_range"].format(
            range=range_text, count=len(models),
        ))
    previous_count = state.role_counts.get(role)
    previous_models = state.role_models.get(role)
    state.role_counts[role] = len(models)
    state.role_models[role] = list(models)
    try:
        _validate_submitted_role_model(state, profile)
    except WizardError:
        if previous_count is None:
            state.role_counts.pop(role, None)
        else:
            state.role_counts[role] = previous_count
        if previous_models is None:
            state.role_models.pop(role, None)
        else:
            state.role_models[role] = previous_models
        raise
    return t["echo_template"].format(value=f"{role}={','.join(models)}")


def _validate_submitted_role_model(
    state: WizardState,
    profile: RoleProfile,
) -> None:
    context, scopes = _load_context(state)
    role_models = {
        role: tuple(models) for role, models in state.role_models.items()
    }
    requirements = _selectable_static_requirements(state, profile)
    complete = all(
        len(role_models.get(requirement.role, ()))
        == _selected_role_count(state, requirement)
        for requirement in requirements
    )
    if complete:
        _validate_role_selection_feasibility(state, profile, context, scopes)
        return
    if not _role_selection_can_complete(
        state, profile, context, role_models, scopes,
    ):
        raise WizardError("selected role model leaves no complete role assignment")


def _submit_role_prompt(state: WizardState, prompt: Prompt, value: str) -> str:
    profile = _load_role_profile_for_state(state)
    role = _step_role(prompt.step)
    requirement = _requirement_for(profile, role)
    if prompt.step.startswith(_MODELS_PREFIX):
        if _fixed_single(requirement):
            raise WizardError(f"role {role!r} takes exactly one model")
        return _submit_role_models(state, profile, requirement, prompt, value)
    # 고정 단일 역할의 `role-model:<role>:1`.
    if not _fixed_single(requirement):
        raise WizardError(f"role {role!r} is chosen on its checkbox step")
    allowed = {option.value for option in prompt.options}
    if value not in allowed:
        raise WizardError(
            f"model {value!r} is not a compatible candidate for role {role!r}"
        )
    previous = state.role_models.get(role)
    state.role_models[role] = [value]
    try:
        _validate_submitted_role_model(state, profile)
    except WizardError:
        if previous is None:
            state.role_models.pop(role, None)
        else:
            state.role_models[role] = previous
        raise
    return f"role-model: {role}#1={value}"


def _reset_role_selection_from(state: WizardState, target_step: str) -> None:
    """편집 대상 step 부터 되감는다 — 그 step 의 역할과 그 뒤 역할의 답을 비운다."""
    try:
        target_index = state.role_selection_order.index(target_step)
    except ValueError as exc:
        raise WizardError(f"unknown role selection step: {target_step!r}") from exc
    removed_roles = {
        _step_role(step_id) for step_id in state.role_selection_order[target_index:]
    }
    _drop_role_selection(state, removed_roles)


def _clear_role_selection(state: WizardState) -> None:
    state.role_counts.clear()
    state.role_models.clear()
    state.role_selection_order.clear()
    state.answered = [
        step_id
        for step_id in state.answered
        if not _is_role_selection_step(step_id)
    ]
