"""Learn a user's preferred output verbosity from past sessions.

The premise (validated on real transcripts): users almost never *say* how terse
they want answers — explicit "be brief" feedback is near-zero — but they *show*
it behaviorally. They interrupt long answers, and they reply faster than a long
answer could possibly have been read. Those signals are mechanical to extract
from Claude Code's JSONL transcripts.

This module:

1. Parses transcripts into per-response records (output tokens, word count,
   timestamps, the preceding turn's structural kind).
2. Extracts behavioral signals — interrupt rate, fast-skip rate, long-output
   frequency, echo ratio — using length-adaptive thresholds (a "fast skip" is
   defined relative to how long the answer would take to *read*, not a fixed
   number of seconds; "long" is relative to the user's own median).
3. Recommends a verbosity level (heuristic prior; an optional LLM judgment pass
   can override it — see ``analyze``).
4. Builds the per-stratum output-token baseline that
   :mod:`headroom.proxy.output_savings` uses as its synthetic control — so the
   same pass that picks the level also establishes how to measure its effect.

The structural signals are *inputs* to the decision, not the decision itself —
which is why thresholds here are interpretable, length-adaptive, and (when an
LLM is available) advisory rather than final.
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from ..proxy.output_savings import BaselineModel, echo_ratio, stratum_key
from ..proxy.output_shaper import classify_turn

logger = logging.getLogger(__name__)

# Average adult reading speed (words/min) for technical prose. Used to turn a
# response's length into "how long it would take to read", so a fast-skip is
# defined relative to answer length rather than a fixed wall-clock cutoff.
_READING_WPM = 250.0
# A reply arriving in less than this fraction of the read-time means the answer
# was almost certainly not read.
_SKIP_READ_FRACTION = 0.5
# Don't score skips on trivially short answers — there's nothing to skip.
_MIN_WORDS_FOR_SKIP = 150
# Floor for the per-user adaptive "long output" threshold (words).
_LONG_OUTPUT_FLOOR = 200
# Only sample the few preceding messages for echo context (bounded cost).
_ECHO_CONTEXT_LOOKBACK = 4

_INTERRUPT_MARKER = "[Request interrupted by user"


@dataclass
class _Response:
    """One assistant response, with the request features that produced it."""

    words: int
    output_tokens: int
    input_tokens: int
    model: str
    turn_kind: str
    has_tools: bool
    ts: float | None
    echo: float


@dataclass
class _HumanMsg:
    ts: float | None
    is_interrupt: bool


@dataclass
class VerbositySignals:
    """Behavioral signals aggregated across a project's sessions."""

    sessions: int = 0
    human_msgs: int = 0
    interrupts: int = 0
    asst_responses: int = 0
    asst_words: int = 0
    long_outputs: int = 0
    fast_skips: int = 0
    skip_eligible: int = 0
    mean_echo_ratio: float = 0.0

    @property
    def interrupt_rate(self) -> float:
        denom = self.human_msgs + self.interrupts
        return self.interrupts / denom if denom else 0.0

    @property
    def fast_skip_rate(self) -> float:
        return self.fast_skips / self.skip_eligible if self.skip_eligible else 0.0

    @property
    def long_output_rate(self) -> float:
        return self.long_outputs / self.asst_responses if self.asst_responses else 0.0

    def to_dict(self) -> dict[str, Any]:
        return {
            "sessions": self.sessions,
            "human_msgs": self.human_msgs,
            "interrupts": self.interrupts,
            "interrupt_rate": round(self.interrupt_rate, 4),
            "asst_responses": self.asst_responses,
            "long_outputs": self.long_outputs,
            "long_output_rate": round(self.long_output_rate, 4),
            "fast_skips": self.fast_skips,
            "skip_eligible": self.skip_eligible,
            "fast_skip_rate": round(self.fast_skip_rate, 4),
            "mean_echo_ratio": round(self.mean_echo_ratio, 4),
        }


@dataclass
class VerbosityProfile:
    """The learned recommendation for a project."""

    project_path: str
    level: int
    confidence: str  # "low" | "medium" | "high"
    source: str  # "heuristic" | "llm"
    rationale: str
    signals: dict[str, Any] = field(default_factory=dict)
    learned_at: str | None = None  # caller stamps (Date.now unavailable here)

    def to_dict(self) -> dict[str, Any]:
        return {
            "project_path": self.project_path,
            "verbosity_level": self.level,
            "confidence": self.confidence,
            "source": self.source,
            "rationale": self.rationale,
            "signals": self.signals,
            "learned_at": self.learned_at,
        }

    @classmethod
    def load(cls, path: Path) -> VerbosityProfile | None:
        try:
            d = json.loads(Path(path).read_text())
        except (OSError, json.JSONDecodeError, ValueError):
            return None
        return cls(
            project_path=d.get("project_path", ""),
            level=int(d.get("verbosity_level", 2)),
            confidence=d.get("confidence", "low"),
            source=d.get("source", "heuristic"),
            rationale=d.get("rationale", ""),
            signals=d.get("signals", {}),
            learned_at=d.get("learned_at"),
        )

    def save(self, path: Path) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(self.to_dict(), indent=2))


def _parse_ts(s: str | None) -> float | None:
    if not s:
        return None
    try:
        from datetime import datetime

        return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
    except (ValueError, TypeError):
        return None


def _assistant_words_and_text(content: Any) -> tuple[int, str]:
    if not isinstance(content, list):
        return 0, ""
    text = " ".join(
        b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
    )
    return len(text.split()), text


def _human_text(content: Any) -> str | None:
    """Return human-typed text, or None for tool results / slash commands / meta."""
    if isinstance(content, str):
        t = content
    elif isinstance(content, list):
        if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content):
            return None
        t = " ".join(
            b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
        )
    else:
        return None
    if "<command-name>" in t or "<local-command-stdout>" in t:
        return None
    return t


def _parse_session(path: Path) -> tuple[list[_Response], list[_HumanMsg], bool]:
    """Parse one transcript into responses + human messages + has_tools flag."""
    responses: list[_Response] = []
    humans: list[_HumanMsg] = []
    has_tools = False
    prior_messages: list[dict[str, Any]] = []
    recent_context: list[str] = []

    try:
        lines = path.read_text().splitlines()
    except (OSError, UnicodeDecodeError):
        return [], [], False

    for line in lines:
        try:
            d = json.loads(line)
        except json.JSONDecodeError:
            continue
        ltype = d.get("type")
        msg = d.get("message", {}) if isinstance(d.get("message"), dict) else {}
        ts = _parse_ts(d.get("timestamp"))

        if ltype == "assistant":
            content = msg.get("content", [])
            if isinstance(content, list) and any(
                isinstance(b, dict) and b.get("type") == "tool_use" for b in content
            ):
                has_tools = True
            words, text = _assistant_words_and_text(content)
            usage = msg.get("usage", {}) if isinstance(msg.get("usage"), dict) else {}
            in_tok = (
                usage.get("input_tokens", 0)
                + usage.get("cache_read_input_tokens", 0)
                + usage.get("cache_creation_input_tokens", 0)
            )
            out_tok = usage.get("output_tokens", 0)
            if words > 0 or out_tok > 0:
                ctx = " ".join(recent_context[-_ECHO_CONTEXT_LOOKBACK:])
                responses.append(
                    _Response(
                        words=words,
                        output_tokens=out_tok,
                        input_tokens=in_tok,
                        model=str(msg.get("model", "")),
                        turn_kind=classify_turn(prior_messages).value,
                        has_tools=False,  # filled after the session scan
                        ts=ts,
                        echo=echo_ratio(text, ctx) if text and ctx else 0.0,
                    )
                )
            prior_messages.append({"role": "assistant", "content": content})

        elif ltype == "user":
            content = msg.get("content")
            prior_messages.append({"role": "user", "content": content})
            # Feed tool results / user text into echo context.
            if isinstance(content, list):
                for b in content:
                    if isinstance(b, dict) and b.get("type") == "tool_result":
                        rc = b.get("content", "")
                        recent_context.append(rc if isinstance(rc, str) else str(rc))
                        has_tools = True
            human = _human_text(content)
            if human is None:
                continue
            if _INTERRUPT_MARKER in human:
                humans.append(_HumanMsg(ts=ts, is_interrupt=True))
            else:
                humans.append(_HumanMsg(ts=ts, is_interrupt=False))
                recent_context.append(human)

    # Backfill has_tools (a session-level property of the harness).
    for r in responses:
        r.has_tools = has_tools
    return responses, humans, has_tools


def _ordered_events(path: Path) -> list[tuple[float | None, str, _Response | _HumanMsg]]:
    """Re-read interleaving order so fast-skip can pair a human reply to the
    assistant response immediately before it. Kept simple: re-parse with a tag.
    """
    out: list[tuple[float | None, str, Any]] = []
    try:
        lines = path.read_text().splitlines()
    except (OSError, UnicodeDecodeError):
        return out
    responses, humans, _ = _parse_session(path)
    # The two lists are already in file order; interleave by re-walking lines.
    ri = hi = 0
    for line in lines:
        try:
            d = json.loads(line)
        except json.JSONDecodeError:
            continue
        ltype = d.get("type")
        if ltype == "assistant" and ri < len(responses):
            out.append((responses[ri].ts, "assistant", responses[ri]))
            ri += 1
        elif ltype == "user":
            msg = d.get("message", {}) if isinstance(d.get("message"), dict) else {}
            text = _human_text(msg.get("content"))
            if text is None:
                continue
            if hi < len(humans):
                out.append((humans[hi].ts, "human", humans[hi]))
                hi += 1
    return out


def extract_signals(
    session_paths: list[Path],
) -> tuple[VerbositySignals, BaselineModel]:
    """Compute behavioral signals and the per-stratum output-token baseline."""
    sig = VerbositySignals()
    baseline = BaselineModel()
    all_response_words: list[int] = []

    # First pass: collect every response word-count to derive the per-user
    # adaptive "long" threshold (median), so "long" scales to the user.
    parsed: list[tuple[list[_Response], list[_HumanMsg]]] = []
    for p in session_paths:
        responses, humans, _ = _parse_session(p)
        if not responses and not humans:
            continue
        sig.sessions += 1
        parsed.append((responses, humans))
        all_response_words.extend(r.words for r in responses if r.words > 0)

    long_threshold = _LONG_OUTPUT_FLOOR
    if all_response_words:
        all_response_words.sort()
        median = all_response_words[len(all_response_words) // 2]
        long_threshold = max(_LONG_OUTPUT_FLOOR, median)

    echo_sum = 0.0
    echo_n = 0
    for responses, humans in parsed:
        for r in responses:
            sig.asst_responses += 1
            sig.asst_words += r.words
            if r.words >= long_threshold:
                sig.long_outputs += 1
            if r.echo > 0:
                echo_sum += r.echo
                echo_n += 1
            if r.output_tokens > 0:
                key = stratum_key(
                    turn_kind=r.turn_kind,
                    input_tokens=r.input_tokens,
                    model=r.model or "unknown",
                    has_tools=r.has_tools,
                )
                baseline.observe(key, r.output_tokens)
        for h in humans:
            if h.is_interrupt:
                sig.interrupts += 1
            else:
                sig.human_msgs += 1

    # Second pass: fast-skip pairing via interleaved order.
    for p in session_paths:
        events = _ordered_events(p)
        last_resp: _Response | None = None
        for ts, kind, obj in events:
            if kind == "assistant":
                last_resp = obj  # type: ignore[assignment]
            elif kind == "human":
                hm: _HumanMsg = obj  # type: ignore[assignment]
                if (
                    last_resp is not None
                    and not hm.is_interrupt
                    and last_resp.words >= _MIN_WORDS_FOR_SKIP
                    and ts is not None
                    and last_resp.ts is not None
                ):
                    sig.skip_eligible += 1
                    read_secs = last_resp.words / _READING_WPM * 60.0
                    if (ts - last_resp.ts) < _SKIP_READ_FRACTION * read_secs:
                        sig.fast_skips += 1
                last_resp = None

    sig.mean_echo_ratio = echo_sum / echo_n if echo_n else 0.0
    return sig, baseline


def recommend_level(sig: VerbositySignals) -> tuple[int, str, str]:
    """Heuristic prior mapping signals → (level, confidence, rationale).

    This is the prior; an LLM judgment pass (in :func:`analyze`) may override
    it. Bands are interpretable: the more a user interrupts and fast-skips, the
    less of the output they consume, so the terser we should make it.
    """
    if sig.human_msgs + sig.interrupts < 10:
        return 2, "low", "Too few human turns to calibrate; defaulting to L2."

    ir = sig.interrupt_rate
    fsr = sig.fast_skip_rate
    pressure = ir + fsr  # combined "too much output" pressure

    confidence = "high" if (sig.human_msgs + sig.interrupts) >= 60 else "medium"

    if pressure < 0.10:
        return (
            1,
            confidence,
            (
                f"Low push-back (interrupt {ir:.0%}, fast-skip {fsr:.0%}); user reads "
                "answers — light touch (L1)."
            ),
        )
    if pressure < 0.30:
        return (
            2,
            confidence,
            (
                f"Moderate push-back (interrupt {ir:.0%}, fast-skip {fsr:.0%}); "
                "drop ceremony and echo (L2)."
            ),
        )
    if pressure < 0.55:
        return (
            3,
            confidence,
            (
                f"High push-back (interrupt {ir:.0%}, fast-skip {fsr:.0%}); user "
                "rarely reads long answers — conclusions only (L3)."
            ),
        )
    return (
        3,
        confidence,
        (
            f"Very high push-back (interrupt {ir:.0%}, fast-skip {fsr:.0%}); capping "
            "at L3 rather than auto-applying caveman L4."
        ),
    )


def analyze(
    session_paths: list[Path],
    project_path: str,
    *,
    llm_judge: Any | None = None,
) -> tuple[VerbosityProfile, BaselineModel]:
    """Full analysis: signals → recommendation → profile, plus the baseline.

    ``llm_judge``, if given, is a callable ``(signals_dict) -> (level, rationale)``
    that overrides the heuristic. Kept injectable so the core stays LLM-free and
    testable; the CLI wires a real LLM call.
    """
    sig, baseline = extract_signals(session_paths)
    level, confidence, rationale = recommend_level(sig)
    source = "heuristic"
    if llm_judge is not None:
        try:
            verdict = llm_judge(sig.to_dict())
            if verdict is not None:
                level, rationale = verdict
                level = max(0, min(4, int(level)))
                source = "llm"
        except Exception as e:  # LLM is advisory — never fail the analysis
            logger.warning("verbosity LLM judge failed, using heuristic: %s", e)

    profile = VerbosityProfile(
        project_path=project_path,
        level=level,
        confidence=confidence,
        source=source,
        rationale=rationale,
        signals=sig.to_dict(),
    )
    return profile, baseline
