"""CLI interactive override gate for ``amx analyze run``.

The gate hangs off ``_maybe_apply_llm_overrides_interactively`` in
``amx.cli_support.commands.analyze_flow``. It must:

* Skip silently when stdin is not a TTY (CI / pipes shouldn't stall
  on a missed prompt) and return a no-op restore.
* Skip when the user declines the first ``confirm``.
* Walk the user through every field with the saved profile values as
  defaults; only typed-in changes become overrides.
* Mutate ``cfg.llm`` for the duration of the run and return a
  ``restore`` callable that bounces ``cfg.llm`` back to the profile
  values on the way out — no on-disk state ever changes.
"""

from __future__ import annotations

import sys
from typing import Any

import pytest

from amx.cli_support.commands import analyze_flow
from amx.config import AMXConfig, LLMConfig


@pytest.fixture()
def cfg_with_profile() -> AMXConfig:
    cfg = AMXConfig()
    cfg.llm = LLMConfig(
        provider="openai",
        model="gpt-4o",
        temperature=0.2,
        max_tokens=16_384,
        n_alternatives=3,
        column_batch_size=10,
        prompt_detail="standard",
        description_verbosity="brief",
        thinking_budget=1024,
        logprob_high=0.85,
        logprob_medium=0.50,
    )
    return cfg


def test_override_gate_noop_on_non_tty(cfg_with_profile, monkeypatch) -> None:
    """A piped invocation must never stall waiting for input."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
    saved_llm = cfg_with_profile.llm
    restore, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)
    assert applied == {}
    assert cfg_with_profile.llm is saved_llm
    restore()  # no-op
    assert cfg_with_profile.llm is saved_llm


def test_override_gate_noop_when_user_declines(cfg_with_profile, monkeypatch) -> None:
    """User answers ``n`` to the gate → no fields prompted, no
    overrides applied, cfg.llm untouched."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: False)
    monkeypatch.setattr(
        "amx.cli_support._analyze_flow_prompts.ask",
        lambda *_a, **_k: pytest.fail("ask must not be called when user declines"),
    )
    saved_llm = cfg_with_profile.llm
    restore, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)
    assert applied == {}
    assert cfg_with_profile.llm is saved_llm
    restore()
    assert cfg_with_profile.llm is saved_llm


def test_override_gate_applies_only_changed_fields(cfg_with_profile, monkeypatch) -> None:
    """User accepts the gate, types a new temperature, accepts every
    other field with Enter. Only the changed field becomes an override
    and ``restore`` puts the saved profile back in place."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: True)

    # Walk through every numeric prompt: only the temperature row
    # gets a new value; everything else returns the empty string
    # (Enter accepts current). Cost-override prompts also return ""
    # so those stay None.
    answers = iter(
        [
            "0.05",  # temperature
            "",  # max_tokens
            "",  # n_alternatives
            "",  # column_batch_size
            # prompt_detail handled via ask_choice
            # description_verbosity handled via ask_choice
            "",  # thinking_budget
            "",  # logprob_high
            "",  # logprob_medium
            "",  # custom_input_cost_per_mtok
            "",  # custom_output_cost_per_mtok
        ]
    )

    def fake_ask(_question: str, *, default: str = "") -> str:
        try:
            return next(answers)
        except StopIteration:
            return ""

    def fake_ask_choice(_q: str, choices: list[str], *, default: str = "", **_k):
        return default  # accept current

    monkeypatch.setattr("amx.cli_support._analyze_flow_prompts.ask", fake_ask)
    monkeypatch.setattr("amx.cli_support._analyze_flow_prompts.ask_choice", fake_ask_choice)

    saved_llm = cfg_with_profile.llm
    restore, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)

    assert applied == {"temperature": 0.05}
    assert cfg_with_profile.llm is not saved_llm  # derived dataclass
    assert cfg_with_profile.llm.temperature == 0.05
    # Every other field carries the saved profile's value through
    # ``dataclasses.replace`` — proving the override only changed
    # what the user typed.
    assert cfg_with_profile.llm.max_tokens == saved_llm.max_tokens
    assert cfg_with_profile.llm.prompt_detail == saved_llm.prompt_detail

    restore()
    assert cfg_with_profile.llm is saved_llm  # original instance back


def test_override_gate_out_of_range_keeps_profile_value(cfg_with_profile, monkeypatch) -> None:
    """A typo (temperature = 5) is rejected with a warning rather
    than silently shipping; cfg.llm.temperature stays at the saved
    profile value."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: True)

    answers = iter(
        [
            "5.0",  # temperature — out of range
            "",  # max_tokens
            "",  # n_alternatives
            "",  # column_batch_size
            "",  # thinking_budget
            "",  # logprob_high
            "",  # logprob_medium
            "",  # custom_input
            "",  # custom_output
        ]
    )
    monkeypatch.setattr(
        "amx.cli_support._analyze_flow_prompts.ask", lambda *_a, **_k: next(answers, "")
    )
    monkeypatch.setattr(
        "amx.cli_support._analyze_flow_prompts.ask_choice",
        lambda _q, _c, *, default="", **_k: default,
    )

    _, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)
    # The bogus 5.0 was discarded — no override slot for temperature.
    assert "temperature" not in applied
    assert cfg_with_profile.llm.temperature == 0.2


def test_override_gate_walks_choice_fields(cfg_with_profile, monkeypatch) -> None:
    """``prompt_detail`` and ``description_verbosity`` are picked from
    a fixed list via ``ask_choice``; a different selection becomes an
    override."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: True)
    monkeypatch.setattr(
        "amx.cli_support._analyze_flow_prompts.ask",
        lambda *_a, **_k: "",  # accept every numeric default
    )

    choice_returns: dict[str, Any] = {
        "Prompt detail": "detailed",
        "Description verbosity": "comprehensive",
    }

    def fake_choice(question: str, choices: list[str], *, default: str = "", **_k):
        for key, value in choice_returns.items():
            if key.lower() in question.lower():
                return value
        return default

    monkeypatch.setattr("amx.cli_support._analyze_flow_prompts.ask_choice", fake_choice)

    _, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)
    assert applied == {
        "prompt_detail": "detailed",
        "description_verbosity": "comprehensive",
    }
    assert cfg_with_profile.llm.prompt_detail == "detailed"
    assert cfg_with_profile.llm.description_verbosity == "comprehensive"


# ── Picker parity: alternatives_mode + confidence_signal ───────────────
#
# Pins the picker's coverage of the two knobs added for parity with the
# Studio "Advanced LLM settings" override panel. Studio's
# ``LLMOverrides`` Pydantic model already accepts both fields; this
# block makes sure the CLI's interactive picker (the parity surface) does
# the same. Per the user spec, CLI per-run flags stay out of scope so
# scripted ``/run`` invocations remain reproducible from ``config.yml``
# alone.


def _all_numeric_blank(monkeypatch) -> None:
    """Helper: monkeypatch ``ask`` to return blank (= accept default)
    for every numeric prompt the picker walks."""
    monkeypatch.setattr("amx.cli_support._analyze_flow_prompts.ask", lambda *_a, **_k: "")


def test_override_gate_walks_alternatives_mode_when_n_gt_1(cfg_with_profile, monkeypatch) -> None:
    """When the effective ``n_alternatives`` is 2 or more, the picker
    prompts for ``alternatives_mode`` and the chosen value lands in the
    derived ``LLMConfig`` without mutating the saved profile."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: True)
    _all_numeric_blank(monkeypatch)

    seen_alt_mode_prompt = False

    def fake_choice(question: str, choices: list[str], *, default: str = "", **_k):
        nonlocal seen_alt_mode_prompt
        if "alternatives mode" in question.lower():
            seen_alt_mode_prompt = True
            assert set(choices) == {"semantic", "lexical"}, (
                "alternatives_mode picker must offer exactly the Definition 1 choices"
            )
            return "lexical"
        return default

    monkeypatch.setattr("amx.cli_support._analyze_flow_prompts.ask_choice", fake_choice)

    saved_llm = cfg_with_profile.llm
    restore, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)

    assert seen_alt_mode_prompt, "picker did not prompt for alternatives_mode"
    assert applied.get("alternatives_mode") == "lexical"
    assert cfg_with_profile.llm.alternatives_mode == "lexical"
    # Saved profile stays untouched — the picker only swaps the derived
    # config onto cfg.llm.
    assert saved_llm.alternatives_mode == "semantic"

    restore()
    assert cfg_with_profile.llm is saved_llm


def test_override_gate_skips_alternatives_mode_when_n_is_1(cfg_with_profile, monkeypatch) -> None:
    """When the user lowers ``n_alternatives`` to 1 in the picker, the
    next prompt jumps past ``alternatives_mode`` — mirrors Studio's
    tile-disabled rule. The override dict carries the new ``n`` but no
    ``alternatives_mode`` entry."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: True)

    answers = iter(
        [
            "",  # temperature
            "",  # max_tokens
            "1",  # n_alternatives → 1 (effective n becomes 1)
            "",  # column_batch_size
            "",  # thinking_budget
            "",  # logprob_high
            "",  # logprob_medium
            "",  # custom_input
            "",  # custom_output
        ]
    )
    monkeypatch.setattr(
        "amx.cli_support._analyze_flow_prompts.ask", lambda *_a, **_k: next(answers, "")
    )

    asked_alt_mode = False

    def fake_choice(question: str, choices: list[str], *, default: str = "", **_k):
        nonlocal asked_alt_mode
        if "alternatives mode" in question.lower():
            asked_alt_mode = True
        return default

    monkeypatch.setattr("amx.cli_support._analyze_flow_prompts.ask_choice", fake_choice)

    _, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)

    assert applied == {"n_alternatives": 1}
    assert "alternatives_mode" not in applied, (
        "alternatives_mode must be skipped when effective n is 1"
    )
    assert asked_alt_mode is False


def test_override_gate_walks_confidence_signal(cfg_with_profile, monkeypatch) -> None:
    """``confidence_signal`` prompt fires regardless of ``n`` (a single
    alternative still carries a confidence band)."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: True)
    _all_numeric_blank(monkeypatch)

    seen_signal_prompt = False

    def fake_choice(question: str, choices: list[str], *, default: str = "", **_k):
        nonlocal seen_signal_prompt
        if "confidence signal" in question.lower():
            seen_signal_prompt = True
            assert "self_consistency" in choices and "judge" in choices
            return "judge"
        return default

    monkeypatch.setattr("amx.cli_support._analyze_flow_prompts.ask_choice", fake_choice)

    saved_llm = cfg_with_profile.llm
    _, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)

    assert seen_signal_prompt, "picker did not prompt for confidence_signal"
    assert applied.get("confidence_signal") == "judge"
    assert cfg_with_profile.llm.confidence_signal == "judge"
    # Saved profile retains the LLMConfig default (self_consistency).
    assert saved_llm.confidence_signal == "self_consistency"


def test_override_gate_keep_default_for_new_fields(cfg_with_profile, monkeypatch) -> None:
    """Pressing Enter on both new rows leaves neither field in the
    applied dict — the picker only records what the user actually
    changed."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: True)
    _all_numeric_blank(monkeypatch)

    monkeypatch.setattr(
        "amx.cli_support._analyze_flow_prompts.ask_choice",
        lambda _q, _choices, *, default="", **_k: default,
    )

    _, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)
    assert "alternatives_mode" not in applied
    assert "confidence_signal" not in applied


def test_override_summary_line_includes_new_overrides(
    cfg_with_profile, monkeypatch, capsys
) -> None:
    """The ``Applied per-run overrides: ...`` line is dict-driven and
    must surface the two new keys when overridden."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
    monkeypatch.setattr(analyze_flow, "confirm", lambda *_a, **_k: True)
    _all_numeric_blank(monkeypatch)

    printed_lines: list[str] = []

    def fake_info(msg: str, *args, **kwargs) -> None:
        printed_lines.append(str(msg))

    monkeypatch.setattr(analyze_flow, "info", fake_info)

    def fake_choice(question: str, choices: list[str], *, default: str = "", **_k):
        if "alternatives mode" in question.lower():
            return "lexical"
        if "confidence signal" in question.lower():
            return "judge"
        return default

    monkeypatch.setattr("amx.cli_support._analyze_flow_prompts.ask_choice", fake_choice)

    _, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)
    assert applied == {
        "alternatives_mode": "lexical",
        "confidence_signal": "judge",
    }
    summary = next(
        (line for line in printed_lines if line.startswith("Applied per-run overrides")), None
    )
    assert summary is not None, "summary line missing from picker output"
    assert "alternatives_mode=lexical" in summary
    assert "confidence_signal=judge" in summary


def test_override_gate_non_interactive_does_not_prompt_for_new_fields(
    cfg_with_profile, monkeypatch
) -> None:
    """Scripted ``/run`` invocations (non-TTY) must never see the new
    prompts — the picker short-circuits at the TTY check. Regression
    pin for the reproducibility contract: ``config.yml`` plus a
    non-interactive run produces deterministic output."""
    monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
    monkeypatch.setattr(
        "amx.cli_support._analyze_flow_prompts.ask_choice",
        lambda *_a, **_k: pytest.fail("ask_choice must not run in non-TTY mode"),
    )
    monkeypatch.setattr(
        "amx.cli_support._analyze_flow_prompts.ask",
        lambda *_a, **_k: pytest.fail("ask must not run in non-TTY mode"),
    )

    saved_llm = cfg_with_profile.llm
    _, applied = analyze_flow._maybe_apply_llm_overrides_interactively(cfg_with_profile)
    assert applied == {}
    assert cfg_with_profile.llm is saved_llm
