"""The diagnosis orchestration — this project's core value.

Given an execution id, answer *"which action died, why, and what was missing
upstream."* The flow (IMPLEMENTATION_HANDOFF.md §Phase 6):

1. ``GET /v3/activity/execution/{id}`` — execution detail.
2. If the status is not a failure family, return immediately (one-line verdict).
3. Feature-detect the ``error`` key; fall back to parsing ``message``.
4. Fetch the bot JSON via the execution's ``fileId``.
5. DFS the bot JSON into an ordered action-node list.
6. Map the failure onto an action node (line number first, order fallback).
7. Analyze the surrounding context — upstream validation, wrapping Try/Catch.
8. If the bot has no Try/Catch, emit an **instrumentation prescription**
   (CLAUDE.md §3.5) — the correct output is a prescription, not "undeterminable".
9. Snapshot the diagnosis locally (Phase 7 — left as a hook here).

Two output contracts:

* **Verdict first.** The ``verdict`` string is the one line an LLM should need;
  the structured fields back it up.
* **Bot JSON never in context.** Only the parsed action surface and (once
  Phase 7 lands) a disk path appear in the result — never the raw bot blob.

``A360AuthError`` propagates (never silenced): an auth failure must not look
like "this execution has no failure".
"""

from __future__ import annotations

import logging
from typing import Any, Protocol

from ..auth.auth_manager import A360AuthError, AuthManager
from .bot import (
    BotNode,
    context_window,
    fetch_bot_content,
    has_error_handler,
    node_label,
    walk_actions,
)
from .execution import (
    ExecutionSummary,
    extract_line_number,
    fetch_execution,
    is_failure_status,
    parse_execution,
)

logger = logging.getLogger(__name__)

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

# How many sibling actions to show on each side of the failure for context.
CONTEXT_BEFORE = 3
CONTEXT_AFTER = 3

# Prescription text emitted when a bot has no Try/Catch. The Catch action
# exposes "Assign the line number to" and "Assign exception message to"; paired
# with "Log to Server" that yields executionId + line + exception as one record
# (CLAUDE.md §3.5). Screenshots require an explicit Screen Capture in Catch.
PRESCRIPTION_NO_ERROR_HANDLER = (
    "This bot has no Try/Catch, so it cannot be diagnosed post-hoc as-is. "
    "Instrument it: wrap the failing region in a Try/Catch, and in the Catch "
    "action set 'Assign the line number to' and 'Assign exception message to' "
    "to variables, then add a 'Log to Server' that emits executionId + line "
    "number + exception message. Add a 'Screen Capture' inside Catch for "
    "screenshots (the platform does not capture them automatically)."
)

# Emitted when the bot logic could not be read at all. Distinct from the
# instrumentation prescription on purpose: claiming "no Try/Catch" for a bot we
# never opened sends the user to re-instrument correct code while the real
# cause (a missing repository permission, a moved file) stays hidden.
PRESCRIPTION_BOT_UNREADABLE = (
    "The bot logic could not be read, so the failing action could not be "
    "located and the error-handler status is unknown. Check that the API "
    "account's role grants View on the bot's folder, that the bot still exists "
    "at the recorded fileId, then re-run this diagnosis with refresh=true."
)

# Emitted when the execution record carries no bot file id at all.
PRESCRIPTION_NO_FILE_ID = (
    "This execution record carries no bot fileId, so the bot logic could not "
    "be located and the error-handler status is unknown. Look the bot up by "
    "name with the repository file list, then diagnose a run that does record "
    "a fileId."
)

# Reason strings for `bot_read_error` when no exception carries one.
BOT_READ_ERROR_NO_FILE_ID = "execution record carries no bot fileId"


# --------------------------------------------------------------------------- #
# Snapshot hook (Phase 7 plugs the real store in here)
# --------------------------------------------------------------------------- #
class SnapshotStore(Protocol):
    """Interface Phase 7's ``LocalSnapshotStore`` satisfies.

    A snapshot store lets the diagnosis flow (a) short-circuit on a cached
    diagnosis so the Control Room is not re-hit, and (b) persist a fresh
    diagnosis for future cache hits. Defined as a Protocol so this module
    imports nothing from Phase 7.
    """

    def get_diagnosis(self, execution_id: str) -> dict | None:
        """Return the cached diagnosis (augmented with snapshot metadata), or None."""
        ...

    def save_diagnosis(
        self,
        *,
        auth: AuthManager,
        execution: ExecutionSummary,
        bot_nodes: list[BotNode],
        diagnosis: dict,
    ) -> str | None: ...


# --------------------------------------------------------------------------- #
# Verdict builder (pure)
# --------------------------------------------------------------------------- #
def build_verdict(
    *,
    execution: ExecutionSummary,
    failed_node: BotNode | None,
    error_message: str,
    has_handler: bool | None,
    missing_upstream_hint: str | None,
    bot_read_error: str | None = None,
) -> str:
    """Compose the one-line verdict an LLM should be able to act on alone.

    Shape: ``action <label> failed: <message>. <upstream hint>. <handler hint>.``
    Each clause is omitted when it has nothing to say, so the verdict stays
    short for the common case and only grows when context adds value.

    ``has_handler`` is tri-state. ``None`` means the bot logic was never read,
    which is *not* the same as "no handler" — the verdict says so rather than
    asserting an absence it never checked.
    """
    location = (
        f"action at line {failed_node.line_number} {node_label(failed_node)}"
        if failed_node and failed_node.line_number
        else f"action {node_label(failed_node)}" if failed_node else "an unknown action"
    )
    clauses = [f"{location} failed: {error_message or 'no message provided'}"]
    if missing_upstream_hint:
        clauses.append(missing_upstream_hint)
    if has_handler is None:
        reason = bot_read_error or "bot logic unavailable"
        clauses.append(f"bot logic could not be read ({reason}), so error handling is unverified")
    elif not has_handler:
        clauses.append("no error handler around it")
    return ". ".join(clauses) + "."


def detect_missing_upstream(
    before_nodes: list[BotNode],
) -> str | None:
    """Heuristic: flag an obvious missing validation before the failure.

    Looks for common "open/use without check" patterns in the preceding
    actions. Conservative on purpose — a false "missing check" hint is noise,
    while a missed one just means the LLM reads the context list itself.
    Returns a short hint string or ``None``.
    """
    if not before_nodes:
        return None
    commands = " ".join((n.command or "").lower() for n in before_nodes if n.command)
    # File/resource open without a preceding existence check.
    has_open = any(marker in commands for marker in ("open", "read", "load", "connect", "download"))
    has_check = any(
        marker in commands
        for marker in ("exist", "check", "verify", "if ", "condition", "validate")
    )
    if has_open and not has_check:
        return "no existence check upstream of the failing action"
    return None


# --------------------------------------------------------------------------- #
# Node serialization (the diagnostic surface — NOT the raw bot JSON)
# --------------------------------------------------------------------------- #
def _node_to_surface(node: BotNode) -> dict:
    """Project a node down to the small dict safe for tool context."""
    return {
        "order": node.order,
        "path": list(node.path),
        "command": node.command,
        "name": node.name,
        "line_number": node.line_number,
        "kind": node.kind,
        "depth": node.depth,
    }


# --------------------------------------------------------------------------- #
# Orchestration
# --------------------------------------------------------------------------- #
def diagnose(
    auth: AuthManager,
    execution_id: str,
    *,
    snapshot_store: SnapshotStore | None = None,
    refresh: bool = False,
) -> dict:
    """Run the full diagnosis flow for ``execution_id``.

    Returns a verdict-first dict. ``A360AuthError`` and ``A360ApiError``
    propagate (never silenced). The raw bot JSON is never placed in the result
    — only the parsed action surface and, when Phase 7 is wired, a disk path.

    ``refresh=True`` bypasses the local snapshot and re-diagnoses against the
    Control Room. Without it, a diagnosis produced during a transient outage
    would be the permanent answer for that execution.
    """
    # Phase 7: serve from local snapshot if we already diagnosed this
    # execution. Skips the Control Room entirely — a performance win and it
    # survives CR's 90-day retention cutoff.
    if snapshot_store is not None and not refresh:
        cached = snapshot_store.get_diagnosis(execution_id)
        if cached is not None:
            return cached

    # Steps 1-2: fetch + parse; bail early on non-failure.
    raw = fetch_execution(auth, execution_id)
    execution = parse_execution(raw)
    if not is_failure_status(execution.status):
        return _non_failure_result(execution)

    # Step 3: resolve the error (feature-detect error key, fallback to message).
    error = execution.error or {}
    error_message = str(error.get("message") or execution.message or "")
    line_number = extract_line_number(error, execution.message)

    # Steps 4-5: fetch + walk the bot. `bot_inspected` is the honest record of
    # whether we ever saw the bot logic — everything downstream keys off it
    # rather than off "is the node list empty", which cannot tell an
    # un-instrumented bot apart from an unreadable one.
    bot_nodes: list[BotNode] = []
    failed_node: BotNode | None = None
    bot_inspected = False
    bot_read_error: str | None = None
    if not execution.file_id:
        bot_read_error = BOT_READ_ERROR_NO_FILE_ID
    else:
        try:
            bot_json = fetch_bot_content(auth, execution.file_id)
            bot_nodes = walk_actions(bot_json)
            failed_node = _resolve_failed_node(bot_nodes, line_number)
            bot_inspected = True
        except Exception as exc:  # noqa: BLE001 - bot fetch failure is not fatal
            # A360AuthError is re-raised inside the helper: "your session died"
            # must never be dressed up as a diagnosis.
            bot_read_error = _bot_read_failure_reason(exc, execution_id)

    # Step 7: surrounding context + error-handler presence. Tri-state: None
    # means "never checked", which the verdict reports as unverified.
    has_handler: bool | None = has_error_handler(bot_nodes) if bot_inspected else None
    before_nodes: list[BotNode] = []
    after_nodes: list[BotNode] = []
    if failed_node is not None:
        ctx = context_window(bot_nodes, failed_node, before=CONTEXT_BEFORE, after=CONTEXT_AFTER)
        before_nodes = ctx["before"]
        after_nodes = ctx["after"]
    missing_hint = detect_missing_upstream(before_nodes)

    # Step 8: prescription. Every branch names a next action (CLAUDE.md safety
    # rule) — instrument the bot, or fix why the bot could not be read.
    prescription = _build_prescription(
        bot_inspected=bot_inspected,
        has_handler=has_handler,
        has_file_id=bool(execution.file_id),
    )

    # Compose the verdict.
    verdict = build_verdict(
        execution=execution,
        failed_node=failed_node,
        error_message=error_message,
        has_handler=has_handler,
        missing_upstream_hint=missing_hint,
        bot_read_error=bot_read_error,
    )

    result: dict[str, Any] = {
        "verdict": verdict,
        "execution_id": execution.id,
        "status": execution.status,
        "bot_name": execution.bot_name,
        "file_id": execution.file_id,
        "failure": {
            "message": error_message,
            "source": error.get("source", "message"),
            "line_number": line_number,
            "error_code": error.get("code"),
        },
        "failed_action": _node_to_surface(failed_node) if failed_node else None,
        "upstream_context": [_node_to_surface(n) for n in before_nodes],
        "downstream_context": [_node_to_surface(n) for n in after_nodes],
        "bot_inspected": bot_inspected,
        "bot_read_error": bot_read_error,
        "has_error_handler": has_handler,
        "prescription": prescription,
        "action_count": len(bot_nodes) if bot_inspected else None,
        # Phase 7 fills these once the snapshot store is wired in.
        "bot_path": None,
        "snapshot_path": None,
    }

    # Step 9: snapshot hook (Phase 7). Best-effort: a snapshot failure must not
    # discard the diagnosis itself.
    #
    # A degraded diagnosis is NOT persisted. The store has no freshness check,
    # so a snapshot written during a transient outage would be served as the
    # permanent answer for this execution forever.
    if snapshot_store is not None and bot_inspected:
        try:
            snapshot_path = snapshot_store.save_diagnosis(
                auth=auth,
                execution=execution,
                bot_nodes=bot_nodes,
                diagnosis=result,
            )
            if snapshot_path:
                result["snapshot_path"] = snapshot_path
        except Exception:  # noqa: BLE001 - snapshot is best-effort
            logger.warning(
                "diagnose_execution(%s): snapshot save failed",
                execution_id,
                exc_info=True,
            )

    return result


def _bot_read_failure_reason(exc: Exception, execution_id: str) -> str:
    """Turn a bot-fetch exception into a short, user-facing reason string.

    ``A360AuthError`` is re-raised rather than described: an expired or evicted
    session is not a property of the bot, and reporting it as one produces a
    confident diagnosis of a bot that was never opened.
    """
    if isinstance(exc, A360AuthError):
        raise exc
    logger.warning(
        "diagnose_execution(%s): bot content fetch failed; diagnosing without action mapping",
        execution_id,
        exc_info=True,
    )
    status = getattr(exc, "status_code", None)
    if status is not None:
        return f"bot content fetch returned HTTP {status}"
    return f"bot content fetch failed ({type(exc).__name__})"


def _build_prescription(
    *,
    bot_inspected: bool,
    has_handler: bool | None,
    has_file_id: bool,
) -> str | None:
    """Pick the prescription for this diagnosis, or ``None`` when none is due.

    Only a bot we actually read and that genuinely lacks a handler gets the
    instrumentation prescription — the other branches say why the bot could
    not be read instead of inventing a finding about it.
    """
    if bot_inspected:
        return PRESCRIPTION_NO_ERROR_HANDLER if has_handler is False else None
    return PRESCRIPTION_BOT_UNREADABLE if has_file_id else PRESCRIPTION_NO_FILE_ID


def _resolve_failed_node(nodes: list[BotNode], line_number: int | None) -> BotNode | None:
    """Map the failure onto a node: line number first, then nothing.

    (The order-based fallback is intentionally not automatic — guessing the
    Nth action from a message with no line number is too unreliable. The
    verdict then names "an unknown action" and the LLM reads the context.)
    """
    if not nodes:
        return None
    if line_number:
        from .bot import find_node_by_line

        return find_node_by_line(nodes, line_number)
    return None


def _non_failure_result(execution: ExecutionSummary) -> dict:
    """Build the short verdict-first result for a non-failing execution."""
    if not execution.status:
        verdict = f"Execution {execution.id or '?'} has no status; nothing to diagnose."
    else:
        verdict = (
            f"Execution {execution.id} status is {execution.status} — not a failure. "
            "Nothing to diagnose."
        )
    return {
        "verdict": verdict,
        "execution_id": execution.id,
        "status": execution.status,
        "bot_name": execution.bot_name,
        "file_id": execution.file_id,
        "failure": None,
        "failed_action": None,
        "upstream_context": [],
        "downstream_context": [],
        # The bot is never fetched on this path — there is nothing to diagnose.
        "bot_inspected": False,
        "bot_read_error": None,
        "has_error_handler": None,
        "prescription": None,
        "action_count": None,
        "bot_path": None,
        "snapshot_path": None,
    }
