import json
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any

from home_agent.contracts.types import (
    Item,
    LLMResult,
    LLMUsage,
    RetrievedMatch,
    UrgencyDecision,
    UrgencyLabel,
)
from home_agent.core.logging import get_logger
from home_agent.utils.subprocess_logging import LoggedSubprocessError, run_logged_subprocess

CLASSIFICATION_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "required": ["score", "label", "reasons"],
    "properties": {
        "score": {"type": "number", "minimum": 0, "maximum": 1},
        "label": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
        "reasons": {"type": "array", "items": {"type": "string"}, "minItems": 1},
    },
}


@dataclass(frozen=True)
class BudgetGuard:
    month_spend_usd: float
    month_cap_usd: float

    def allows_call(self) -> bool:
        return self.month_spend_usd < self.month_cap_usd


class ClaudeHeadlessRunner:
    def __init__(self, command: str = "claude", timeout_seconds: float = 120) -> None:
        self.command: str = command
        self.timeout_seconds: float = timeout_seconds

    def classify(
        self, item: Item, retrieved_matches: list[RetrievedMatch] | None = None
    ) -> LLMResult:
        logger = get_logger(__name__).bind(
            llm_provider="claude",
            provider=item.provider,
            external_id=item.external_id,
        )
        prompt = (
            "Return JSON with score(0-1), label(low|medium|high|critical), reasons(array). "
            f"Subject: {item.subject}; Sender: {item.sender}; Metadata: {json.dumps(item.metadata)}"
        )
        prompt += _render_retrieval_prompt_suffix(retrieved_matches)
        logger.debug("llm.classify.start", retrieved_match_count=len(retrieved_matches or []))
        try:
            proc = run_logged_subprocess(
                [
                    self.command,
                    "-p",
                    prompt,
                    "--output-format",
                    "json",
                    "--json-schema",
                    json.dumps(CLASSIFICATION_SCHEMA),
                ],
                logger_name=__name__,
                event="llm.claude.command",
                check=True,
                timeout=self.timeout_seconds,
            )
        except LoggedSubprocessError as exc:
            logger.error("llm.classify.failed", returncode=exc.returncode)
            raise RuntimeError(_subprocess_error_message(exc, "claude call failed")) from exc

        payload = _parse_json_object(proc.stdout)
        structured = payload.get("structured_output")
        if not isinstance(structured, dict):
            msg = (
                "Claude CLI did not return structured_output. "
                "Ensure --json-schema is supported by your Claude CLI version."
            )
            raise RuntimeError(msg)
        parsed = {str(k): v for k, v in structured.items()}

        usage = LLMUsage(
            provider="claude",
            model=_as_str(payload.get("model")),
            total_cost_usd=_as_float(payload.get("total_cost_usd")),
            input_tokens=_as_int(payload.get("input_tokens")),
            output_tokens=_as_int(payload.get("output_tokens")),
            raw=payload,
        )

        decision = _decision_from_payload(parsed, used_llm=True)
        logger.debug(
            "llm.classify.completed",
            label=decision.label.value,
            score=decision.score,
            total_cost_usd=usage.total_cost_usd,
        )
        return LLMResult(decision=decision, usage=usage, raw_response=payload)


class CodexExecRunner:
    def __init__(self, command: str = "codex", timeout_seconds: float = 120) -> None:
        self.command: str = command
        self.timeout_seconds: float = timeout_seconds

    def classify(
        self, item: Item, retrieved_matches: list[RetrievedMatch] | None = None
    ) -> LLMResult:
        logger = get_logger(__name__).bind(
            llm_provider="codex",
            provider=item.provider,
            external_id=item.external_id,
        )
        prompt = (
            "Return strict JSON object with keys score,label,reasons where label is "
            "low|medium|high|critical. "
            f"Subject: {item.subject}; Sender: {item.sender}; Metadata: {json.dumps(item.metadata)}"
        )
        prompt += _render_retrieval_prompt_suffix(retrieved_matches)
        logger.debug("llm.classify.start", retrieved_match_count=len(retrieved_matches or []))
        try:
            proc = run_logged_subprocess(
                [self.command, "exec", "--json", prompt],
                logger_name=__name__,
                event="llm.codex.command",
                check=True,
                timeout=self.timeout_seconds,
            )
        except LoggedSubprocessError as exc:
            logger.error("llm.classify.failed", returncode=exc.returncode)
            raise RuntimeError(_subprocess_error_message(exc, "codex call failed")) from exc

        events = _parse_jsonl(proc.stdout)
        final_payload = events[-1] if events else {}

        result_text = _extract_codex_result_text(events)
        parsed = _safe_parse_result(result_text)

        usage = LLMUsage(
            provider="codex",
            model=_as_str(final_payload.get("model")),
            total_cost_usd=_as_float(final_payload.get("total_cost_usd")),
            input_tokens=_as_int(final_payload.get("input_tokens")),
            output_tokens=_as_int(final_payload.get("output_tokens")),
            raw={"events": events},
        )
        decision = _decision_from_payload(parsed, used_llm=True)
        logger.debug(
            "llm.classify.completed",
            label=decision.label.value,
            score=decision.score,
            total_cost_usd=usage.total_cost_usd,
            event_count=len(events),
        )
        return LLMResult(decision=decision, usage=usage, raw_response=final_payload)


def current_month_key(now: datetime | None = None) -> str:
    dt = now or datetime.now(tz=UTC)
    return f"{dt.year:04d}-{dt.month:02d}"


def _decision_from_payload(payload: dict[str, object], used_llm: bool) -> UrgencyDecision:
    score_raw = payload.get("score", 0.5)
    if not isinstance(score_raw, int | float | str):
        score_raw = 0.5

    reasons_raw = payload.get("reasons", ["llm-classifier"])
    if not isinstance(reasons_raw, list):
        reasons_raw = ["llm-classifier"]

    return UrgencyDecision(
        score=float(score_raw),
        label=_to_label(str(payload.get("label", "medium"))),
        reasons=[str(reason) for reason in reasons_raw],
        used_llm=used_llm,
    )


def _parse_json_object(raw: str) -> dict[str, Any]:
    payload = json.loads(raw)
    if isinstance(payload, dict):
        return payload
    msg = "unexpected JSON payload type"
    raise RuntimeError(msg)


def _parse_jsonl(raw: str) -> list[dict[str, Any]]:
    events: list[dict[str, Any]] = []
    for line in raw.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        try:
            parsed = json.loads(stripped)
        except json.JSONDecodeError:
            continue
        if isinstance(parsed, dict):
            events.append(parsed)
    return events


def _extract_codex_result_text(events: list[dict[str, Any]]) -> str:
    for event in reversed(events):
        for key in ("result", "output", "message", "text"):
            value = event.get(key)
            if isinstance(value, str) and value.strip():
                return value
            if isinstance(value, dict):
                nested = value.get("content")
                if isinstance(nested, str) and nested.strip():
                    return nested
    return "{}"


def _safe_parse_result(result_str: str) -> dict[str, object]:
    """Parse JSON result string. Used by CodexExecRunner."""
    try:
        parsed = json.loads(result_str.strip())
        if isinstance(parsed, dict):
            return {str(k): v for k, v in parsed.items()}
    except json.JSONDecodeError:
        pass
    return {"score": 0.5, "label": "medium", "reasons": ["llm-unparseable"]}


def _subprocess_error_message(exc: LoggedSubprocessError, default: str) -> str:
    if exc.timeout_seconds is not None:
        return str(exc)
    return exc.stderr.strip() or exc.stdout.strip() or default


def _to_label(value: str) -> UrgencyLabel:
    lowered = value.lower()
    if lowered == "critical":
        return UrgencyLabel.CRITICAL
    if lowered == "high":
        return UrgencyLabel.HIGH
    if lowered == "low":
        return UrgencyLabel.LOW
    return UrgencyLabel.MEDIUM


def _as_float(value: object) -> float | None:
    if value is None:
        return None
    if isinstance(value, int | float):
        return float(value)
    if isinstance(value, str):
        try:
            return float(value)
        except ValueError:
            return None
    return None


def _as_int(value: object) -> int | None:
    if value is None:
        return None
    if isinstance(value, int):
        return value
    if isinstance(value, float):
        return int(value)
    if isinstance(value, str):
        try:
            return int(value)
        except ValueError:
            return None
    return None


def _as_str(value: object) -> str | None:
    if isinstance(value, str):
        return value
    return None


def _render_retrieval_prompt_suffix(retrieved_matches: list[RetrievedMatch] | None) -> str:
    if not retrieved_matches:
        return ""
    lines = [" Similar prior items:"]
    for match in retrieved_matches:
        label = match.urgency_label.value if match.urgency_label is not None else "unknown"
        lines.append(
            f" [{match.kind.value}] {match.summary_text}; sender/provider={match.provider}; received_at={match.received_at.isoformat()}; prior_label={label}; similarity={match.similarity:.2f}"
        )
    return "\n".join(lines)
