"""Execution-domain logic for the diagnosis feature.

This module turns raw Control Room execution records into the small, typed
shapes the diagnosis pipeline needs. It is deliberately *permissive*: the exact
``DetailedError`` schema and the full ``status`` enum are **not confirmed** from
the OpenAPI spec (see CLAUDE.md), so every parser feature-detects at runtime
and falls back to the human-readable ``message`` rather than branching on a
Control Room version.

The two hard rules from the auth/data contracts hold here:

* ``A360AuthError`` is **never** caught — a caller must be able to tell
  "not logged in" apart from "0 executions". Conflating them makes the LLM
  chase records that were simply invisible.
* Only the *parsed* ``ExecutionSummary`` ever reaches tool context. The raw
  record (which can carry large nested payloads) stays out of the response —
  the diagnosis tool returns the verdict and the failure surface, never the
  whole execution blob.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass

from ..auth.auth_manager import AuthManager
from ..tools.a360_api import get_record, list_all

logger = logging.getLogger(__name__)

# --------------------------------------------------------------------------- #
# Constants
# --------------------------------------------------------------------------- #

# Path templates. Centralized so a path change is a one-line edit.
EXECUTION_DETAIL_PATH = "/v3/activity/execution/{execution_id}"
ACTIVITY_LIST_PATH = "/v3/activity/list"

# A permissive superset of the failure-family statuses seen in the spec
# (``RUN_FAILED``, ``RUN_ABORTED``, ``RUN_TIMED_OUT``, ``DEPLOY_FAILED``).
# The exact enum set is [UNVERIFIED], so ``is_failure_status`` matches both the
# known set and substrings — a new failure variant must not read as "success".
KNOWN_FAILED_STATUSES = frozenset(
    {
        "RUN_FAILED",
        "RUN_ABORTED",
        "RUN_TIMED_OUT",
        "DEPLOY_FAILED",
        "RUN_FAILED_BUSINESS_CONDITION",
        "RUN_FAILED_DOWNLOAD",
        "RUN_FAILED_UPLOAD",
        "RUN_NO_DEVICE_AVAILABLE",
        "RUN_FAILED_LAUNCH",
    }
)

# Substrings that mark a status as a failure even if the exact enum is unknown.
# Kept broad on purpose: a miss here would make a failed run look successful.
FAILED_STATUS_MARKERS = ("FAIL", "ABORT", "TIMEOUT", "TIMED_OUT", "ERROR")

# Keys that may carry the bot file id on an execution record. The exact key is
# [UNVERIFIED] across CR versions, so the first non-empty match wins.
FILE_ID_KEYS = ("fileId", "botFileId", "botContentFileId", "botId")

# Keys that may carry the line number inside a ``DetailedError`` object.
LINE_NUMBER_KEYS = ("lineNumber", "line_number", "line", "lineNo", "failedLineNumber")

# Regexes for pulling a line number out of a free-form ``message`` string.
# A360 messages vary; cover the common phrasings without over-fitting.
_LINE_NUMBER_PATTERNS = (
    re.compile(r"line\s*(?:number)?[:\s]*(\d+)", re.IGNORECASE),
    re.compile(r"at\s+line\s+(\d+)", re.IGNORECASE),
    re.compile(r"step\s*(?:number)?[:\s]*(\d+)", re.IGNORECASE),
)

# Default caps for ``find_executions``. Activity history is large; an unbounded
# walk would hammer the session budget for no diagnostic gain.
DEFAULT_FIND_MAX_ITEMS = 50
DEFAULT_FIND_PAGE_SIZE = 100


# --------------------------------------------------------------------------- #
# Typed shapes
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ExecutionSummary:
    """The small, typed view of an execution record that diagnosis needs.

    ``raw`` is retained for reference but must never be serialized into tool
    context verbatim — callers project the fields they need from this summary.
    """

    id: str
    status: str
    message: str
    file_id: str | None
    deployment_id: str | None
    bot_name: str | None
    started_on: str | None
    completed_on: str | None
    error: dict | None
    raw: dict


# --------------------------------------------------------------------------- #
# Status helpers
# --------------------------------------------------------------------------- #
def is_failure_status(status: str | None) -> bool:
    """Return True for any failure-family status.

    Permissive by design: matches the known enum set OR any failure marker
    substring. A new failure variant must never read as success — that would
    make ``diagnose_execution`` skip the failure branch entirely.
    """
    if not status:
        return False
    norm = status.strip()
    if norm in KNOWN_FAILED_STATUSES:
        return True
    upper = norm.upper()
    return any(marker in upper for marker in FAILED_STATUS_MARKERS)


# --------------------------------------------------------------------------- #
# Parsers (pure)
# --------------------------------------------------------------------------- #
def _first_present(raw: dict, keys: tuple[str, ...]) -> str | None:
    """Return the first non-empty string value among ``keys`` in ``raw``."""
    for key in keys:
        value = raw.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
        if isinstance(value, int) and not isinstance(value, bool):
            text = str(value)
            if text != "0":  # 0 is almost certainly an unset placeholder
                return text
    return None


def extract_line_number(error: dict | None, message: str | None) -> int | None:
    """Pull a line number from a DetailedError, falling back to ``message``.

    Feature-detects the line-number key at runtime (the field name is
    [UNVERIFIED]); if absent, scans ``message`` for common phrasings.
    Returns ``None`` when nothing parseable is present.
    """
    if isinstance(error, dict):
        for key in LINE_NUMBER_KEYS:
            value = error.get(key)
            if isinstance(value, int) and not isinstance(value, bool) and value > 0:
                return value
            if isinstance(value, str) and value.strip().isdigit():
                parsed = int(value.strip())
                if parsed > 0:
                    return parsed
    if isinstance(message, str):
        for pattern in _LINE_NUMBER_PATTERNS:
            match = pattern.search(message)
            if match:
                parsed = int(match.group(1))
                if parsed > 0:
                    return parsed
    return None


def extract_error(raw: dict) -> dict | None:
    """Feature-detect the structured ``error`` key, falling back to ``message``.

    Per CLAUDE.md: detect ``error`` at runtime; if absent or empty, synthesize a
    minimal error object from ``message``. **Never branch on CR version** — the
    version that introduced ``DetailedError`` is unconfirmed and the structured
    form is a decomposition of ``message`` anyway.

    The returned dict always carries ``source`` ("error" or "message") and a
    ``message`` string so downstream code has one shape to handle.
    """
    error = raw.get("error")
    if isinstance(error, dict) and error:
        normalized = dict(error)
        normalized.setdefault("source", "error")
        # Guarantee a message string exists for the verdict builder.
        if not normalized.get("message"):
            normalized["message"] = str(raw.get("message") or "")
        return normalized

    message = raw.get("message")
    if isinstance(message, str) and message.strip():
        return {"message": message.strip(), "source": "message"}
    return None


def parse_execution(raw: dict) -> ExecutionSummary:
    """Build an :class:`ExecutionSummary` from a raw execution record.

    Permissive about the exact field names — the file-id key varies across CR
    versions, so ``_first_present`` resolves it. ``raw`` is retained for
    reference but callers must not dump it into tool context verbatim.
    """
    if not isinstance(raw, dict):  # pragma: no cover - defensive
        raise TypeError(f"parse_execution expects a dict, got {type(raw)!r}")
    return ExecutionSummary(
        id=str(raw.get("id") or raw.get("executionId") or ""),
        status=str(raw.get("status") or ""),
        message=str(raw.get("message") or ""),
        file_id=_first_present(raw, FILE_ID_KEYS),
        deployment_id=_first_present(raw, ("deploymentId", "deployment_id")),
        bot_name=_first_present(raw, ("botName", "bot_name", "automationName", "name")),
        started_on=_first_present(raw, ("startedOn", "started_on", "startTime")),
        completed_on=_first_present(raw, ("completedOn", "completed_on", "endTime")),
        error=extract_error(raw),
        raw=raw,
    )


# --------------------------------------------------------------------------- #
# Network helpers (thin wrappers over the data-access layer)
# --------------------------------------------------------------------------- #
def fetch_execution(auth: AuthManager, execution_id: str) -> dict:
    """GET ``/v3/activity/execution/{id}`` and return the raw record.

    Raises ``A360ApiError`` on HTTP errors; lets ``A360AuthError`` propagate.
    """
    if not execution_id:
        raise ValueError("execution_id is required")
    path = EXECUTION_DETAIL_PATH.format(execution_id=execution_id)
    return get_record(auth, path)


def find_executions(
    auth: AuthManager,
    *,
    deployment_id: str | None = None,
    status: str | None = None,
    bot_id: str | None = None,
    max_items: int = DEFAULT_FIND_MAX_ITEMS,
    page_size: int = DEFAULT_FIND_PAGE_SIZE,
) -> list[dict]:
    """Find executions via ``POST /v3/activity/list``.

    Any of the filters may be ``None`` (omitted from the request body). Whether
    ``deploymentId`` is filterable is [UNVERIFIED] against a live CR — the
    filter is built permissively and a 400 from the server surfaces as
    ``A360ApiError`` rather than being swallowed.

    ``A360AuthError`` propagates (never silenced — auth failure must not look
    like "0 executions").
    """
    from ..tools.a360_api import build_filter, condition

    conds: list[dict] = []
    if deployment_id:
        conds.append(condition("deploymentId", "eq", deployment_id))
    if status:
        conds.append(condition("status", "eq", status))
    if bot_id:
        conds.append(condition("fileId", "eq", bot_id))
    flt = build_filter(*conds)

    return list_all(
        auth,
        ACTIVITY_LIST_PATH,
        filter=flt,
        max_items=max_items,
        page_size=page_size,
    )
