"""A deterministic troubleshooting agent with bounded, testable tools."""

from __future__ import annotations

import math
import queue
import re
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TypeVar, cast

PLAYBOOKS = {
    "network": "Check the timeout, retry transient failures with backoff, and log the final status code.",
    "dependency": "Recreate the environment, pin the dependency, and record the failing version.",
    "general": "Reproduce the issue with the smallest input, capture the error, and add a regression test.",
}


@dataclass(frozen=True)
class Action:
    name: str
    arguments: dict[str, str] = field(default_factory=dict)


@dataclass
class Task:
    request: str
    observations: list[str] = field(default_factory=list)
    trace: list[str] = field(default_factory=list)
    answer: str | None = None


def classify_issue(text: str) -> str:
    if type(text) is not str:
        raise TypeError("text must be a string")

    lowered = text.casefold()
    if any(
        re.search(pattern, lowered)
        for pattern in (
            r"\btimeouts?\b",
            r"\btime(?:d)?\s+out\b",
            r"\bnetworks?\b",
            r"\bapis?\b",
        )
    ):
        return "network"
    if any(
        re.search(pattern, lowered)
        for pattern in (
            r"\bimports?\b",
            r"\bpackages?\b",
            r"\bdependenc(?:y|ies)\b",
        )
    ):
        return "dependency"
    return "general"


def lookup_playbook(category: str) -> str:
    if type(category) is not str:
        raise TypeError("category must be a string")
    if category not in PLAYBOOKS:
        raise ValueError(f"Unknown category: {category}")
    return PLAYBOOKS[category]


TOOLS: dict[str, Callable[..., str]] = {
    "classify_issue": classify_issue,
    "lookup_playbook": lookup_playbook,
}

EXPECTED_ARGUMENTS = {
    "classify_issue": {"text": str},
    "lookup_playbook": {"category": str},
}

T = TypeVar("T")


class _CallTimedOut(Exception):
    """Internal signal used to distinguish call timeouts from user errors."""


def _validate_action(action: object) -> Action:
    if not isinstance(action, Action):
        raise TypeError("Planner must return an Action")
    if type(action.name) is not str:
        raise TypeError("Action name must be a string")
    if type(action.arguments) is not dict:
        raise TypeError("Action arguments must be a dictionary")
    if any(type(key) is not str for key in action.arguments):
        raise TypeError("Action argument names must be strings")
    return action


def _validate_arguments(
    action_name: str,
    arguments: dict[str, object],
    schema: dict[str, type],
) -> None:
    if set(arguments) != set(schema):
        raise ValueError(f"Invalid arguments for {action_name}")
    for name, expected_type in schema.items():
        if type(arguments[name]) is not expected_type:
            raise TypeError(
                f"Argument '{name}' for {action_name} must be "
                f"{expected_type.__name__}"
            )


def _validate_task(task: object) -> Task:
    if not isinstance(task, Task):
        raise TypeError("task must be a Task")
    if type(task.request) is not str:
        raise TypeError("Task request must be a string")
    if type(task.observations) is not list or any(
        type(item) is not str for item in task.observations
    ):
        raise TypeError("Task observations must be a list of strings")
    if type(task.trace) is not list or any(
        type(item) is not str for item in task.trace
    ):
        raise TypeError("Task trace must be a list of strings")
    if task.answer is not None and type(task.answer) is not str:
        raise TypeError("Task answer must be a string or None")
    return task


def _validate_timeout(name: str, value: float | None) -> float | None:
    if value is None:
        return None
    if type(value) not in (int, float) or not math.isfinite(value) or value <= 0:
        raise ValueError(f"{name} must be a positive finite number or None")
    return float(value)


def _call_with_timeout(
    callback: Callable[[], T], timeout_seconds: float | None
) -> T:
    """Return on timeout even if a provider or tool call remains blocked.

    Python threads cannot be forcibly stopped. A timed-out callback continues in
    a daemon thread, so real providers and tools still need their own transport
    timeouts to cancel network or subprocess work and prevent external effects.
    """
    if timeout_seconds is None:
        return callback()

    results: queue.Queue[tuple[bool, object]] = queue.Queue(maxsize=1)

    def invoke() -> None:
        try:
            results.put((True, callback()))
        except BaseException as error:
            results.put((False, error))

    worker = threading.Thread(target=invoke, daemon=True)
    worker.start()
    worker.join(timeout_seconds)
    if worker.is_alive():
        raise _CallTimedOut

    succeeded, result = results.get_nowait()
    if not succeeded:
        raise cast(BaseException, result)
    return cast(T, result)


def _bounded_call(
    callback: Callable[[], T],
    *,
    label: str,
    call_timeout_seconds: float | None,
    deadline_at: float | None,
    deadline_seconds: float | None,
) -> T:
    remaining = None if deadline_at is None else deadline_at - time.monotonic()
    if remaining is not None and remaining <= 0:
        raise TimeoutError(
            f"Agent exceeded the {deadline_seconds:g}-second wall-clock deadline"
        )

    effective_timeout = call_timeout_seconds
    deadline_is_limit = False
    if remaining is not None and (
        effective_timeout is None or remaining <= effective_timeout
    ):
        effective_timeout = remaining
        deadline_is_limit = True

    try:
        result = _call_with_timeout(callback, effective_timeout)
    except _CallTimedOut:
        if deadline_is_limit:
            raise TimeoutError(
                f"Agent exceeded the {deadline_seconds:g}-second wall-clock deadline"
            ) from None
        raise TimeoutError(
            f"{label} exceeded its {call_timeout_seconds:g}-second timeout"
        ) from None

    if deadline_at is not None and time.monotonic() >= deadline_at:
        raise TimeoutError(
            f"Agent exceeded the {deadline_seconds:g}-second wall-clock deadline"
        )
    return result


def decide(task: Task) -> Action:
    """Stand in for a model so every action stays free and deterministic."""
    if len(task.observations) == 0:
        return Action("classify_issue", {"text": task.request})
    if len(task.observations) == 1:
        return Action("lookup_playbook", {"category": task.observations[0]})
    return Action("finish", {"answer": task.observations[-1]})


def execute(action: Action) -> str:
    action = _validate_action(action)
    tool = TOOLS.get(action.name)
    if tool is None:
        raise ValueError(f"Unknown tool: {action.name}")
    _validate_arguments(
        action.name, action.arguments, EXPECTED_ARGUMENTS[action.name]
    )
    result = tool(**action.arguments)
    if type(result) is not str:
        raise TypeError(f"Tool {action.name} must return a string")
    return result


def run_agent(
    task: Task,
    planner: Callable[[Task], Action] = decide,
    max_steps: int = 3,
    *,
    deadline_seconds: float | None = 5.0,
    planner_timeout_seconds: float | None = 2.0,
    tool_timeout_seconds: float | None = 2.0,
) -> Task:
    """Run within separate step, wall-clock, planner, and tool budgets.

    ``max_steps`` limits decisions, not elapsed time. ``deadline_seconds`` caps
    the whole run, while the planner and tool timeouts cap individual calls.
    Passing ``None`` disables a time limit. Transport-level timeouts are still
    required to cancel the underlying provider, network, or subprocess work.
    """
    task = _validate_task(task)
    if not callable(planner):
        raise TypeError("planner must be callable")
    if type(max_steps) is not int or max_steps < 1:
        raise ValueError("max_steps must be at least 1")

    deadline_seconds = _validate_timeout("deadline_seconds", deadline_seconds)
    planner_timeout_seconds = _validate_timeout(
        "planner_timeout_seconds", planner_timeout_seconds
    )
    tool_timeout_seconds = _validate_timeout(
        "tool_timeout_seconds", tool_timeout_seconds
    )
    deadline_at = (
        None if deadline_seconds is None else time.monotonic() + deadline_seconds
    )

    for step in range(1, max_steps + 1):
        planner_task = Task(
            request=task.request,
            observations=list(task.observations),
            trace=list(task.trace),
            answer=task.answer,
        )
        action = _bounded_call(
            lambda: planner(planner_task),
            label="Planner",
            call_timeout_seconds=planner_timeout_seconds,
            deadline_at=deadline_at,
            deadline_seconds=deadline_seconds,
        )
        action = _validate_action(action)
        task.trace.append(f"{step}. {action.name}")

        if action.name == "finish":
            _validate_arguments("finish", action.arguments, {"answer": str})
            task.answer = action.arguments["answer"]
            return task

        observation = _bounded_call(
            lambda: execute(action),
            label=f"Tool {action.name}",
            call_timeout_seconds=tool_timeout_seconds,
            deadline_at=deadline_at,
            deadline_seconds=deadline_seconds,
        )
        task.observations.append(observation)

    raise RuntimeError(f"Agent exceeded the {max_steps}-step budget")


if __name__ == "__main__":
    result = run_agent(Task("My API request times out"))
    print("\n".join(result.trace))
    print(f"\nAnswer: {result.answer}")
