"""Run a workflow by launching an agent session and feeding it the steps.

The executor is intentionally thin: it leans entirely on agent_manager's
existing launch + send_message path so a scheduled run looks identical to
a manual chat. That keeps the MCP gate, action filtering, provider
routing, retries, and history all aligned with the rest of the app.
"""

import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional

from backend.apps.agents.core.models import AgentConfig
from backend.apps.workflows.models import Workflow, WorkflowRun
from backend.apps.workflows import storage

logger = logging.getLogger(__name__)


# In-process map: workflow_id -> currently running run id. Prevents two
# overlapping fires for the same workflow (e.g. cron tick races a manual
# Run button) without serializing across the whole executor.
_running: dict[str, str] = {}
_running_lock = asyncio.Lock()


def _resolve_system_prompt(wf: Workflow) -> Optional[str]:
    if wf.use_synced_prompt:
        return None
    return wf.system_prompt or None


def _resolve_allowed_tools(wf: Workflow) -> list[str]:
    if not wf.actions.freeze:
        return []
    return list(wf.actions.configured_sets)


def _persist_run_fields(wf: Workflow, run_fields: dict, schedule_runs_count_delta: int = 0) -> None:
    """Merge run-side fields into the current on-disk workflow.

    The executor holds the `wf` it was launched with; meanwhile the user
    may have PATCHed unrelated fields (title, schedule, permissions...).
    Saving our captured `wf` would clobber those edits. Re-read the
    authoritative record from storage and only mutate the run-side fields
    we own. If the workflow has been deleted while we ran, silently skip
    the save so we don't resurrect a deleted record.

    schedule_runs_count_delta is a small int (0 or 1) that we add to the
    on-disk schedule.runs_count to avoid the same race overwriting an
    in-flight bump on the user's PATCH path.
    """
    fresh = storage.get_workflow(wf.id)
    if fresh is None:
        # Deleted while we ran. Don't resurrect.
        return
    for k, v in run_fields.items():
        setattr(fresh, k, v)
    if schedule_runs_count_delta:
        fresh.schedule.runs_count = fresh.schedule.runs_count + schedule_runs_count_delta
    storage.save_workflow(fresh)


def _monthly_spend_so_far(wf: Workflow) -> float:
    """Sum cost_usd across runs of `wf` started in the last 30 days.

    Reads the bounded run log (200 rows max per workflow), so this is
    O(history) and runs once per fire. Naive datetimes (legacy rows) are
    treated as host-local then normalized to UTC by Python's astimezone.
    """
    cutoff = datetime.now(timezone.utc) - timedelta(days=30)
    total = 0.0
    for r in storage.list_runs(wf.id, limit=200):
        started = r.started_at
        if started is None:
            continue
        if started.tzinfo is None:
            started = started.astimezone(timezone.utc)
        else:
            started = started.astimezone(timezone.utc)
        if started >= cutoff:
            total += float(r.cost_usd or 0.0)
    return total


async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: Optional[datetime] = None) -> WorkflowRun:
    from backend.apps.agents.agent_manager import agent_manager

    run = WorkflowRun(
        workflow_id=wf.id,
        status="running",
        scheduled_for=scheduled_for,
        started_at=datetime.now(),
        triggered_by=triggered_by,
    )

    # Cost cap pre-check happens before claiming `_running` so a capped
    # workflow doesn't block its own next fire. We still record the run so
    # the user sees it in History with a clear reason.
    if wf.cost_cap_usd_monthly is not None:
        spent = _monthly_spend_so_far(wf)
        if spent >= wf.cost_cap_usd_monthly:
            run.status = "skipped"
            run.error = f"Monthly cost cap reached (${spent:.2f} / ${wf.cost_cap_usd_monthly:.2f})"
            run.finished_at = datetime.now()
            storage.record_run(run)
            _persist_run_fields(wf, {
                "last_run_at": run.finished_at,
                "last_run_status": "skipped",
                "last_run_id": run.id,
            })
            return run

    storage.record_run(run)

    async with _running_lock:
        if wf.id in _running:
            run.status = "skipped"
            run.error = "Previous run still active"
            run.finished_at = datetime.now()
            storage.record_run(run)
            return run
        _running[wf.id] = run.id

    wf.last_run_at = run.started_at
    wf.last_run_status = "running"
    wf.last_run_id = run.id
    _persist_run_fields(wf, {
        "last_run_at": run.started_at,
        "last_run_status": "running",
        "last_run_id": run.id,
    })

    session = None
    try:
        steps = [s.text for s in wf.steps if s.text and s.text.strip()]
        if not steps:
            raise ValueError("Workflow has no steps")

        config = AgentConfig(
            name=wf.title or "Workflow",
            model=wf.model or "sonnet",
            mode=wf.mode or "agent",
            provider=wf.provider or "anthropic",
            system_prompt=_resolve_system_prompt(wf),
            allowed_tools=_resolve_allowed_tools(wf) or [
                "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion",
            ],
            dashboard_id=wf.dashboard_id,
        )

        session = await agent_manager.launch_agent(config)
        run.session_id = session.id
        storage.record_run(run)

        # Background poller: surface the latest tool-call name as a
        # live "what's the agent doing" subtitle on the workflow:run
        # ws event. Cheap enough to run at 1.5s cadence; nothing else
        # is watching session.messages from here. Cancelled in the
        # finally block alongside _running cleanup.
        async def _watch_tool_calls() -> None:
            last_seen = ""
            while True:
                try:
                    await asyncio.sleep(1.5)
                    sess = agent_manager.sessions.get(session.id)
                    if not sess:
                        return
                    msgs = getattr(sess, "messages", []) or []
                    label = ""
                    for m in reversed(msgs):
                        if getattr(m, "role", None) != "tool_call":
                            continue
                        content = getattr(m, "content", None)
                        # Content can be a string, a dict with "name", or
                        # a list of blocks. Pick the first tool_use name.
                        if isinstance(content, list):
                            for b in content:
                                if isinstance(b, dict) and b.get("type") == "tool_use":
                                    label = str(b.get("name") or "")
                                    break
                        elif isinstance(content, dict):
                            label = str(content.get("name") or "")
                        elif isinstance(content, str):
                            label = content[:60]
                        if label:
                            break
                    if label and label != last_seen:
                        last_seen = label
                        run.last_tool_label = label
                        try:
                            from backend.apps.agents.core.ws_manager import ws_manager
                            await ws_manager.broadcast_global("workflow:run", {
                                "workflow_id": wf.id,
                                "run": run.model_dump(mode="json"),
                            })
                        except Exception:
                            pass
                except asyncio.CancelledError:
                    return
                except Exception:
                    return

        watcher_task = asyncio.create_task(_watch_tool_calls())

        # Send each step sequentially. agent_manager.send_message is a no-op
        # while a prior turn is still streaming, so we await until the
        # session is idle before posting the next step. Keeps the runner
        # safe regardless of how long each turn takes.
        step_error: Optional[str] = None
        for idx, step in enumerate(steps):
            # Broadcast the step bump before sending so RunningView flips
            # the disc immediately, not after the agent finishes the step.
            run.active_step_idx = idx
            run.last_tool_label = None
            try:
                from backend.apps.agents.core.ws_manager import ws_manager as _wsm
                await _wsm.broadcast_global("workflow:run", {
                    "workflow_id": wf.id,
                    "run": run.model_dump(mode="json"),
                })
            except Exception:
                pass
            await agent_manager.send_message(session.id, step)
            await _await_session_idle(session.id)
            sess_state = agent_manager.sessions.get(session.id)
            if sess_state is not None and getattr(sess_state, "status", None) == "error":
                step_error = "Agent session entered error state"
                # Pin active step so FailedView can render the X on the
                # right row. error_step_idx == active_step_idx at fail time.
                break

        run.finished_at = datetime.now()
        sess_state = agent_manager.sessions.get(session.id)
        if sess_state is not None:
            run.cost_usd = float(getattr(sess_state, "cost_usd", 0.0) or 0.0)

        if step_error is not None:
            run.status = "failure"
            run.error = step_error
            wf.last_run_status = "failure"
        elif scheduled_for is not None and (run.finished_at.replace(tzinfo=None) - scheduled_for.replace(tzinfo=None)).total_seconds() > 300:
            # Started more than 5 minutes after its slot (app was closed,
            # event loop backed up, etc.). Surface in History as ran_late
            # so the user can tell apart "fired on time" from "caught up".
            # Strip tz before the subtraction so a UTC-aware scheduled_for
            # (new code path) and a naive finished_at don't raise.
            run.status = "ran_late"
            wf.last_run_status = "ran_late"
        else:
            run.status = "success"
            wf.last_run_status = "success"
        # Bump runs_count for scheduled fires that reached a terminal state
        # other than "skipped". Manual runs don't count against max_runs.
        runs_delta = 1 if (triggered_by == "schedule" and run.status in ("success", "ran_late", "failure")) else 0
        storage.record_run(run)
        wf.last_run_at = run.finished_at
        _persist_run_fields(wf, {
            "last_run_at": run.finished_at,
            "last_run_status": wf.last_run_status,
        }, schedule_runs_count_delta=runs_delta)
    except Exception as e:
        logger.exception("Workflow run failed: %s", e)
        run.status = "failure"
        run.error = str(e)[:500]
        run.finished_at = datetime.now()
        storage.record_run(run)
        wf.last_run_status = "failure"
        _persist_run_fields(wf, {
            "last_run_status": "failure",
            "last_run_at": run.finished_at,
        })
    finally:
        # Cancel the tool-call watcher before we tear the session down so
        # the next poll doesn't race close_session.
        try:
            watcher_task.cancel()  # type: ignore[name-defined]
        except Exception:
            pass
        # Close the workflow's agent session so closed_at is set and the
        # run shows up in chat history (get_history sorts by closed_at;
        # sessions with closed_at=None sort to the bottom and fall off
        # the first page). close_session also drops in-memory state and
        # persists the final snapshot to disk.
        if session is not None:
            try:
                await agent_manager.close_session(session.id)
            except Exception:
                logger.exception("close_session failed for workflow run %s", run.id)
        async with _running_lock:
            _running.pop(wf.id, None)

    try:
        from backend.apps.workflows.notifier import notify_run_complete
        await notify_run_complete(wf, run)
    except Exception:
        logger.debug("notifier failed", exc_info=True)

    try:
        from backend.apps.agents.core.ws_manager import ws_manager
        await ws_manager.broadcast_global("workflow:run", {
            "workflow_id": wf.id,
            "run": run.model_dump(mode="json"),
        })
    except Exception:
        pass

    return run


async def _await_session_idle(session_id: str, timeout_s: float = 600.0) -> None:
    """Block until the agent session reaches a non-running terminal state.

    Polls cheaply (50ms) since the agent_manager doesn't expose a per-session
    completion future. Bounded by timeout_s so a stuck step doesn't hang the
    runner forever.
    """
    from backend.apps.agents.agent_manager import agent_manager

    deadline = asyncio.get_event_loop().time() + timeout_s
    while True:
        sess = agent_manager.sessions.get(session_id)
        if not sess:
            return
        task = agent_manager.tasks.get(session_id)
        if task is not None and task.done():
            return
        status = getattr(sess, "status", None)
        if status in ("completed", "error", "stopped"):
            return
        if asyncio.get_event_loop().time() > deadline:
            raise TimeoutError(f"Step exceeded {timeout_s}s on session {session_id}")
        await asyncio.sleep(0.05)
