"""Host-neutral wizard prompts, interaction plans, and answer protocols."""
from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Literal


@dataclass(frozen=True)
class WizardOption:
    value: str
    label: str
    description: str = ""


@dataclass(frozen=True)
class WizardPrompt:
    step: str
    kind: Literal["pick", "text", "pick_group", "done", "aborted"]
    label: str = ""
    options: tuple[WizardOption, ...] = ()
    help: str = ""
    echo_template: str = ""
    multi: bool = False
    questions: tuple["WizardPrompt", ...] = ()

    @property
    def id(self) -> str:
        """Stable public identifier shared by static and role-instance prompts."""
        return self.step


@dataclass(frozen=True)
class AnswerProtocol:
    kind: Literal["exact-value", "numbered", "group-json", "numbered-group-json"]
    multi: bool = False


@dataclass(frozen=True)
class InteractionPlan:
    kind: Literal[
        "native-single",
        "native-multi",
        "native-group",
        "numbered-single",
        "numbered-multi",
        "sequential-group",
        "plain-text",
    ]
    answer_protocol: AnswerProtocol


class WizardAnswerError(ValueError):
    """Raised when an answer violates the interaction plan's protocol."""


def _exact_value_match(prompt: WizardPrompt, candidate: str) -> str | None:
    for option in prompt.options:
        if option.value == candidate:
            return option.value
    return None


def _normalize_numbered_item(prompt: WizardPrompt, answer: str) -> str:
    candidate = (answer or "").strip()
    # A bare number is the position in the list the user was shown. Matching
    # option values first breaks every picker whose values are themselves
    # numbers — a stage picker resolves "1" to stage 1 while the user was
    # pointing at line 1, which is a different stage (or "every stage").
    if candidate.isdecimal():
        number = int(candidate)
        if 1 <= number <= len(prompt.options):
            return prompt.options[number - 1].value
        # 옵션 값 자체가 숫자인 role-count 는 목록 밖 번호를 값으로도 허용한다
        # (예: 옵션 [3,4,5] 에서 "4" → 수량 4). 목록에 없으면 범위 오류.
        if prompt.step.startswith("role-count:"):
            exact_count = _exact_value_match(prompt, candidate)
            if exact_count is not None:
                return exact_count
            role = prompt.step.split(":", 1)[1]
            minimum = prompt.options[0].value if prompt.options else "?"
            maximum = prompt.options[-1].value if prompt.options else "?"
            raise WizardAnswerError(
                f"role {role!r} count must be in {minimum}..{maximum}: "
                f"{candidate}"
            )
        out_of_list = _exact_value_match(prompt, candidate)
        if out_of_list is not None:
            return out_of_list
        raise WizardAnswerError(f"numbered-text answer is out of range: {candidate}")
    exact_value = _exact_value_match(prompt, candidate)
    if exact_value is not None:
        return exact_value
    label_values = tuple(
        option.value for option in prompt.options if option.label == candidate
    )
    if len(label_values) == 1:
        return label_values[0]
    if len(label_values) > 1:
        raise WizardAnswerError(
            f"numbered-text answer label is ambiguous: {candidate}"
        )
    raise WizardAnswerError(f"numbered-text answer is not an option: {candidate}")


def _normalize_numbered_answer(
    prompt: WizardPrompt,
    answer: str,
    *,
    multi: bool,
) -> str:
    if not multi:
        return _normalize_numbered_item(prompt, answer)
    if not (answer or "").strip():
        return ""
    return ",".join(
        _normalize_numbered_item(prompt, item)
        for item in answer.split(",")
    )


def _group_answers(answer: str) -> dict[str, object]:
    try:
        answers = json.loads(answer or "{}")
    except json.JSONDecodeError as exc:
        raise WizardAnswerError(f"group answer must be a JSON object: {exc}") from exc
    if not isinstance(answers, dict):
        raise WizardAnswerError("group answer must be a JSON object")
    return answers


def _normalize_group_answer(
    prompt: WizardPrompt,
    answer: str,
    *,
    numbered: bool,
) -> str:
    answers = _group_answers(answer)
    if not numbered:
        return json.dumps(answers, ensure_ascii=False, separators=(",", ":"))
    questions = {question.step: question for question in prompt.questions}
    normalized = dict(answers)
    for step, raw_answer in answers.items():
        question = questions.get(step)
        if question is None:
            continue
        normalized[step] = _normalize_numbered_answer(
            question,
            str(raw_answer or ""),
            multi=question.multi,
        )
    return json.dumps(normalized, ensure_ascii=False, separators=(",", ":"))


def normalize_planned_answer(
    prompt: WizardPrompt,
    protocol: AnswerProtocol,
    answer: str,
) -> str:
    if protocol.kind == "exact-value":
        return answer
    if protocol.kind == "numbered":
        return _normalize_numbered_answer(prompt, answer, multi=protocol.multi)
    if protocol.kind in {"group-json", "numbered-group-json"}:
        return _normalize_group_answer(
            prompt,
            answer,
            numbered=protocol.kind == "numbered-group-json",
        )
    raise WizardAnswerError(f"unsupported answer protocol: {protocol.kind}")
