"""
Phase 31: Auto-Dispatch Engine — Automatic Task Routing.

When a task comes in with required skills, Brynq automatically finds the
best available agent and routes it — no human clicking buttons needed.

How it works:
  1. WATCH — Monitor the task queue for new pending tasks
  2. MATCH — Score available agents by skill overlap, reputation, workload
  3. ASSIGN — Auto-assign the best match and notify them
  4. ESCALATE — If no match found, post to #general for manual pickup
  5. REBALANCE — Periodically check for stuck/orphaned tasks and re-route
  6. LAUNCH — If a matching profile exists but agent is offline, auto-launch it
  7. MONITOR — Detect stalled tasks and reassign or escalate

Storage:
  state/broker/dispatch/
    rules.json         — dispatch rules and priorities
    history.jsonl      — dispatch decisions log
  state/broker/autodispatch/
    dispatch_log.jsonl — full lifecycle dispatch events
    launched.json      — currently launched agent processes
"""

import json
import os
import subprocess
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from brynq.server import (
    BROKER_DIR,
    CHANNELS_DIR,
    SESSIONS_DIR,
    SESSION_ID,
    SESSION_NAME,
    PLATFORM,
    _write_json_sync,
    _append_jsonl_sync,
    _read_jsonl_sync,
)

# ── Paths ──────────────────────────────────────────────────────────────────

DISPATCH_DIR = BROKER_DIR / "dispatch"
RULES_FILE = DISPATCH_DIR / "rules.json"
HISTORY_FILE = DISPATCH_DIR / "history.jsonl"
TASKS_DIR = BROKER_DIR / "tasks"
PROFILES_DIR = BROKER_DIR / "profiles"
CAPABILITIES_DIR = BROKER_DIR / "capabilities"

AUTODISPATCH_DIR = BROKER_DIR / "autodispatch"
DISPATCH_LOG = AUTODISPATCH_DIR / "dispatch_log.jsonl"
LAUNCHED_FILE = AUTODISPATCH_DIR / "launched.json"
LAUNCHERS_DIR = BROKER_DIR / "launchers"

for _d in (DISPATCH_DIR, AUTODISPATCH_DIR, LAUNCHERS_DIR):
    _d.mkdir(parents=True, exist_ok=True)


# ── Agent Scoring ──────────────────────────────────────────────────────────


def score_agent_for_task(agent_profile: dict, task: dict) -> float:
    """Score how well an agent matches a task. Higher = better fit.

    Factors:
      - Skill overlap (0-60 points)
      - Reputation (0-20 points)
      - Workload / availability (0-20 points)
    """
    score = 0.0

    # Skill matching
    agent_skills = [s.lower() for s in agent_profile.get("skills", [])]
    task_desc = (task.get("title", "") + " " + task.get("description", "")).lower()
    task_skills = _extract_skills_from_text(task_desc)

    if task_skills:
        matched = sum(1 for s in task_skills if s in agent_skills)
        score += (matched / len(task_skills)) * 60
    else:
        score += 30  # No specific skills required = everyone gets base score

    # Reputation
    rep = agent_profile.get("reputation", 5.0)
    score += min(rep, 5.0) * 4  # Max 20 points

    # Availability bonus
    avail = agent_profile.get("availability", "available")
    if avail == "available":
        score += 20
    elif avail == "busy":
        score += 5
    # offline/exiled = 0

    # Penalize high workload
    active_tasks = agent_profile.get("active_task_count", 0)
    score -= active_tasks * 10

    return round(max(score, 0), 2)


def _extract_skills_from_text(text: str) -> list[str]:
    """Extract likely skill keywords from task text."""
    known_skills = [
        "python", "javascript", "typescript", "rust", "go", "java",
        "react", "fastapi", "django", "flask", "node",
        "testing", "code_review", "security", "debugging", "architecture",
        "deployment", "devops", "database", "sql", "api", "frontend",
        "backend", "ml", "machine_learning", "data", "documentation",
    ]
    text_lower = text.lower()
    return [s for s in known_skills if s in text_lower]


# ── Dispatch Logic ─────────────────────────────────────────────────────────


def find_best_agent(task: dict) -> Optional[dict]:
    """Find the best available agent for a task.

    Returns the agent profile with match score, or None.
    """
    if not PROFILES_DIR.exists():
        return None

    candidates = []
    for f in PROFILES_DIR.glob("*.json"):
        if f.name.startswith("_"):
            continue
        try:
            profile = json.loads(f.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue

        # Skip unavailable agents
        if profile.get("availability") in ("offline", "exiled"):
            continue

        score = score_agent_for_task(profile, task)
        if score > 0:
            candidates.append({**profile, "dispatch_score": score})

    if not candidates:
        return None

    candidates.sort(key=lambda c: c["dispatch_score"], reverse=True)
    return candidates[0]


def dispatch_task(task_id: str) -> dict:
    """Auto-dispatch a pending task to the best available agent.

    Returns the dispatch result.
    """
    task = _load_task(task_id)
    if not task:
        return {"status": "error", "reason": f"Task {task_id} not found"}

    if task.get("status") != "pending":
        return {"status": "skipped", "reason": f"Task is '{task['status']}', not pending"}

    best = find_best_agent(task)
    if not best:
        # No match — escalate to #general
        _escalate_task(task)
        _log_dispatch(task_id, "escalated", "No matching agent found")
        return {"status": "escalated", "task_id": task_id, "reason": "No matching agent"}

    # Assign the task
    task["status"] = "accepted"
    task["assignee_session"] = best.get("session_id", "")
    task["assignee_name"] = f"{best.get('session_name', '')}@{best.get('platform', '')}"
    task["accepted_at"] = datetime.now(timezone.utc).isoformat()
    task["auto_dispatched"] = True
    _save_task(task)

    # Notify the agent
    _notify_agent(best, task)
    _log_dispatch(task_id, "dispatched", f"Assigned to {task['assignee_name']} (score: {best['dispatch_score']})")

    return {
        "status": "dispatched",
        "task_id": task_id,
        "assigned_to": task["assignee_name"],
        "score": best["dispatch_score"],
    }


def dispatch_all_pending() -> dict:
    """Scan all pending tasks and auto-dispatch them.

    This is the main entry point — call periodically or on new task creation.
    """
    if not TASKS_DIR.exists():
        return {"dispatched": 0, "escalated": 0, "skipped": 0}

    results = {"dispatched": 0, "escalated": 0, "skipped": 0, "details": []}

    for f in sorted(TASKS_DIR.glob("*.json")):
        try:
            task = json.loads(f.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue

        if task.get("status") != "pending":
            continue

        result = dispatch_task(task["task_id"])
        results[result["status"]] = results.get(result["status"], 0) + 1
        results["details"].append(result)

    return results


# ── Rebalancing ────────────────────────────────────────────────────────────


def rebalance_stuck_tasks(stuck_minutes: int = 30) -> dict:
    """Find tasks that have been accepted but not completed within the threshold.

    Re-queues them as pending for re-dispatch.
    """
    if not TASKS_DIR.exists():
        return {"requeued": 0}

    from datetime import timedelta
    cutoff = (datetime.now(timezone.utc) - timedelta(minutes=stuck_minutes)).isoformat()
    requeued = []

    for f in TASKS_DIR.glob("*.json"):
        try:
            task = json.loads(f.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue

        if task.get("status") != "accepted":
            continue

        accepted_at = task.get("accepted_at", "")
        if accepted_at and accepted_at < cutoff:
            task["status"] = "pending"
            task["assignee_session"] = None
            task["assignee_name"] = None
            task["accepted_at"] = None
            task["requeued_at"] = datetime.now(timezone.utc).isoformat()
            task["requeue_count"] = task.get("requeue_count", 0) + 1
            _save_task(task)
            _log_dispatch(task["task_id"], "requeued", f"Stuck for >{stuck_minutes}min")
            requeued.append(task["task_id"])

    return {"requeued": len(requeued), "task_ids": requeued}


# ── Dispatch Rules ─────────────────────────────────────────────────────────


def set_dispatch_rules(
    auto_dispatch: bool = True,
    min_score: float = 20.0,
    max_concurrent_per_agent: int = 3,
    escalation_channel: str = "general",
    rebalance_minutes: int = 30,
) -> dict:
    """Configure dispatch behavior."""
    rules = {
        "auto_dispatch": auto_dispatch,
        "min_score": min_score,
        "max_concurrent_per_agent": max_concurrent_per_agent,
        "escalation_channel": escalation_channel,
        "rebalance_minutes": rebalance_minutes,
        "updated": datetime.now(timezone.utc).isoformat(),
    }
    _write_json_sync(RULES_FILE, rules)
    return rules


def get_dispatch_rules() -> dict:
    """Get current dispatch rules."""
    try:
        return json.loads(RULES_FILE.read_text("utf-8"))
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return {
            "auto_dispatch": True,
            "min_score": 20.0,
            "max_concurrent_per_agent": 3,
            "escalation_channel": "general",
            "rebalance_minutes": 30,
        }


# ── History & Stats ────────────────────────────────────────────────────────


def get_dispatch_history(limit: int = 50) -> list[dict]:
    """Get recent dispatch decisions."""
    try:
        entries = []
        with open(HISTORY_FILE, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line:
                    try:
                        entries.append(json.loads(line))
                    except json.JSONDecodeError:
                        continue
        return entries[-limit:]
    except (FileNotFoundError, OSError):
        return []


def get_dispatch_stats() -> dict:
    """Get aggregate dispatch statistics."""
    history = get_dispatch_history(limit=10000)
    by_status = {}
    for entry in history:
        s = entry.get("action", "unknown")
        by_status[s] = by_status.get(s, 0) + 1

    return {
        "total_dispatches": len(history),
        "by_action": by_status,
        "dispatch_rate": round(
            by_status.get("dispatched", 0) / max(len(history), 1), 3
        ),
    }


# ── Task Pull Queue (M3) ───────────────────────────────────────────────────


def pull_next_task(
    agent_id: str,
    agent_skills: Optional[list[str]] = None,
    min_priority: str = "low",
) -> Optional[dict]:
    """Agent pulls the next best-matching pending task from the queue.

    Instead of push (dispatch assigns), this is pull (agent asks for work).
    Returns the task and auto-accepts it, or None if nothing matches.
    """
    if not TASKS_DIR.exists():
        return None

    priority_rank = {"low": 0, "normal": 1, "high": 2, "urgent": 3}
    min_rank = priority_rank.get(min_priority, 0)
    skills = [s.lower() for s in (agent_skills or [])]

    candidates = []
    for f in TASKS_DIR.glob("*.json"):
        try:
            task = json.loads(f.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue

        if task.get("status") != "pending":
            continue

        task_rank = priority_rank.get(task.get("priority", "normal"), 1)
        if task_rank < min_rank:
            continue

        # Score by skill match + priority
        task_text = (task.get("title", "") + " " + task.get("description", "")).lower()
        skill_hits = sum(1 for s in skills if s in task_text) if skills else 1
        score = skill_hits * 10 + task_rank * 5
        candidates.append((score, task))

    if not candidates:
        return None

    # Best match first
    candidates.sort(key=lambda x: x[0], reverse=True)
    best_task = candidates[0][1]

    # Auto-accept
    best_task["status"] = "accepted"
    best_task["assignee_session"] = agent_id
    best_task["assignee_name"] = agent_id
    best_task["accepted_at"] = datetime.now(timezone.utc).isoformat()
    best_task["pulled"] = True
    _save_task(best_task)

    _log_dispatch(best_task["task_id"], "pulled", f"Pulled by {agent_id}")
    return best_task


def get_queue_depth(min_priority: str = "low") -> dict:
    """Get the number of pending tasks in the queue, by priority."""
    if not TASKS_DIR.exists():
        return {"total": 0, "by_priority": {}}

    priority_rank = {"low": 0, "normal": 1, "high": 2, "urgent": 3}
    min_rank = priority_rank.get(min_priority, 0)
    by_priority: dict[str, int] = {}
    total = 0

    for f in TASKS_DIR.glob("*.json"):
        try:
            task = json.loads(f.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue
        if task.get("status") != "pending":
            continue
        p = task.get("priority", "normal")
        if priority_rank.get(p, 1) >= min_rank:
            by_priority[p] = by_priority.get(p, 0) + 1
            total += 1

    return {"total": total, "by_priority": by_priority}


# ── Internals ──────────────────────────────────────────────────────────────


def _load_task(task_id: str) -> Optional[dict]:
    path = TASKS_DIR / f"{task_id}.json"
    try:
        return json.loads(path.read_text("utf-8"))
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return None


def _save_task(task: dict) -> None:
    _write_json_sync(TASKS_DIR / f"{task['task_id']}.json", task)


def _notify_agent(agent: dict, task: dict) -> None:
    """Post a notification to the agent via #_system channel."""
    _append_jsonl_sync(CHANNELS_DIR / "_system.jsonl", {
        "id": uuid.uuid4().hex[:12],
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "session_id": "auto-dispatch",
        "session_name": "Brynq-Dispatch",
        "platform": "system",
        "channel": "_system",
        "msg_type": "request",
        "message": (
            f"[AUTO-DISPATCH] Task assigned to you: {task.get('title', 'Untitled')}\n"
            f"ID: {task['task_id']} | Priority: {task.get('priority', 'normal')}\n"
            f"Channel: #{task.get('channel', 'tasks')}\n"
            f"Score: {agent.get('dispatch_score', '?')}"
        ),
        "target_agent": agent.get("session_id", ""),
    })


def _escalate_task(task: dict) -> None:
    """Post an unmatched task to the escalation channel."""
    rules = get_dispatch_rules()
    channel = rules.get("escalation_channel", "general")
    _append_jsonl_sync(CHANNELS_DIR / f"{channel}.jsonl", {
        "id": uuid.uuid4().hex[:12],
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "session_id": "auto-dispatch",
        "session_name": "Brynq-Dispatch",
        "platform": "system",
        "channel": channel,
        "msg_type": "request",
        "message": (
            f"[NEEDS AGENT] No matching agent found for: {task.get('title', 'Untitled')}\n"
            f"ID: {task['task_id']} | Priority: {task.get('priority', 'normal')}\n"
            f"Skills needed: {task.get('description', '')[:200]}\n"
            f"Any available agent — please accept this task."
        ),
    })


def _log_dispatch(task_id: str, action: str, detail: str) -> None:
    _append_jsonl_sync(HISTORY_FILE, {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "task_id": task_id,
        "action": action,
        "detail": detail,
    })


# ── Lifecycle Event Logging ──────────────────────────────────────────────


def _log_dispatch_event(event_type: str, task_title: str, details: dict) -> dict:
    """Write a structured event to the full dispatch log."""
    entry = {
        "id": uuid.uuid4().hex[:12],
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "event": event_type,
        "task_title": task_title,
        "dispatcher_session": SESSION_ID,
        **details,
    }
    _append_jsonl_sync(DISPATCH_LOG, entry)
    return entry


def _notify_system(message: str) -> None:
    """Post a notification to the #_system channel."""
    _append_jsonl_sync(CHANNELS_DIR / "_system.jsonl", {
        "id": uuid.uuid4().hex[:12],
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "session_id": SESSION_ID,
        "session_name": "brynq-autodispatch",
        "platform": "brynq",
        "channel": "_system",
        "msg_type": "status",
        "message": message,
    })


# ── Launched Agent Registry ──────────────────────────────────────────────


def _load_launched() -> list:
    """Load the list of agent processes launched by autodispatch."""
    if not LAUNCHED_FILE.exists():
        return []
    try:
        data = json.loads(LAUNCHED_FILE.read_text("utf-8"))
        return data if isinstance(data, list) else []
    except (json.JSONDecodeError, OSError):
        return []


def _save_launched(entries: list) -> None:
    _write_json_sync(LAUNCHED_FILE, entries)


# ── Agent Launcher ────────────────────────────────────────────────────────


def _create_agent_bat(name: str, platform: str, task: str, cwd: str) -> Path:
    """Create a .bat launcher file for an agent."""
    safe_task = task.replace('"', "'").replace("\n", " ").replace("\r", "")
    cli_commands = {"claude": "claude", "gemini": "gemini", "ollama": "ollama run llama3"}
    cli_cmd = cli_commands.get(platform, "claude")

    system_prompt = (
        f"You are agent '{name}', auto-launched by Brynq autodispatch. "
        f"Check broker_inbox immediately. Your task: {safe_task} "
        f"Post status updates to #_system. Claim files before editing. "
        f"When finished, use broker_complete_task. Follow the Brynq Constitution."
    )

    if platform == "ollama":
        bat_content = (
            f"@echo off\ntitle Brynq Agent: {name}\ncd /d \"{cwd}\"\n"
            f"set PYTHONPATH=src\nset BROKER_SESSION_NAME={name}\n"
            f"set BROKER_PLATFORM={platform}\necho {system_prompt}\n{cli_cmd}\npause\n"
        )
    else:
        bat_content = (
            f"@echo off\ntitle Brynq Agent: {name}\ncd /d \"{cwd}\"\n"
            f"set PYTHONPATH=src\nset BROKER_SESSION_NAME={name}\n"
            f"set BROKER_PLATFORM={platform}\n"
            f"{cli_cmd} --append-system-prompt \"{system_prompt}\"\npause\n"
        )

    bat_path = LAUNCHERS_DIR / f"autodispatch-{name}.bat"
    bat_path.write_text(bat_content, encoding="utf-8")
    return bat_path


def auto_launch_agent(
    platform: str,
    task_description: str,
    channel: str = "_system",
    cwd: Optional[str] = None,
    name: Optional[str] = None,
) -> dict:
    """Launch an agent process in a new terminal window.

    Args:
        platform: One of 'claude', 'gemini', 'ollama'.
        task_description: What the agent should work on.
        channel: Broker channel for launch notification.
        cwd: Working directory. Defaults to current directory.
        name: Agent name. Auto-generated if not provided.

    Returns:
        Dict with process info or error.
    """
    if platform not in ("claude", "gemini", "ollama"):
        return {"error": f"Unsupported platform '{platform}'. Must be claude, gemini, or ollama."}

    cwd = cwd or os.getcwd()
    name = name or f"auto-{platform}-{uuid.uuid4().hex[:6]}"

    bat_path = _create_agent_bat(name, platform, task_description, cwd)

    pid = None
    try:
        CREATE_NEW_CONSOLE = 0x00000010
        proc = subprocess.Popen(
            ["cmd", "/c", "start", "", str(bat_path)],
            creationflags=CREATE_NEW_CONSOLE,
        )
        pid = proc.pid
    except Exception as exc:
        _log_dispatch_event("launch_failed", task_description, {
            "agent_name": name, "platform": platform, "error": str(exc),
        })
        _notify_system(f"Failed to launch agent '{name}' on {platform}: {exc}")
        return {"error": str(exc)}

    # Track launched agent
    record = {
        "id": uuid.uuid4().hex[:12], "name": name, "platform": platform,
        "task_description": task_description[:300], "bat_path": str(bat_path),
        "pid": pid, "launched_at": datetime.now(timezone.utc).isoformat(), "status": "running",
    }
    entries = _load_launched()
    entries.append(record)
    _save_launched(entries)

    _append_jsonl_sync(CHANNELS_DIR / f"{channel}.jsonl", {
        "id": uuid.uuid4().hex[:12],
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "session_id": SESSION_ID, "session_name": "brynq-autodispatch",
        "platform": "brynq", "channel": channel, "msg_type": "status",
        "message": f"[AUTODISPATCH] Launched {name}@{platform} for task: {task_description[:200]}",
    })

    _log_dispatch_event("agent_launched", task_description, {
        "agent_name": name, "platform": platform, "pid": pid, "bat_path": str(bat_path),
    })

    return {"status": "launched", "name": name, "platform": platform, "pid": pid, "bat_path": str(bat_path)}


# ── Create + Dispatch (from dashboard) ───────────────────────────────────


def create_and_dispatch(
    title: str,
    description: str,
    required_skills: list,
    priority: str = "normal",
    channel: str = "tasks",
) -> dict:
    """Create a new task AND auto-dispatch it to the best agent.

    This is the full lifecycle entry point used by the dashboard. It:
      1. Searches online agents with matching skills
      2. If none, checks profiles for offline agents to launch
      3. If no match, posts as unassigned and escalates

    Args:
        title: Short task title.
        description: Full task description.
        required_skills: List of skill strings needed.
        priority: One of 'low', 'normal', 'high', 'urgent'.
        channel: Broker channel for the task.

    Returns:
        Dict with dispatch result.
    """
    from brynq.capability import find_agents, delegate_task
    from brynq.profiles import find_bots_for_skills

    dispatch_start = time.monotonic()
    result = {"title": title, "priority": priority, "channel": channel, "required_skills": required_skills}

    # Step 1: Search online agents
    online_candidates = []
    for skill in required_skills:
        agents = find_agents(capability=skill)
        for agent in agents:
            if not any(c["session_id"] == agent["session_id"] for c in online_candidates):
                online_candidates.append(agent)

    if online_candidates:
        online_candidates.sort(key=lambda a: a.get("score", 0), reverse=True)
        best = online_candidates[0]
        delegation = delegate_task(
            title=title, description=description,
            required_capability=required_skills[0] if required_skills else "general",
            channel=channel, priority=priority,
        )
        dispatch_time = time.monotonic() - dispatch_start
        result.update({
            "action": "delegated_online", "task_id": delegation.get("task_id"),
            "assigned_to": best.get("session_name"), "assigned_platform": best.get("platform"),
            "dispatch_time_ms": round(dispatch_time * 1000, 1),
        })
        _log_dispatch_event("dispatched_online", title, result)
        _notify_system(f"[AUTODISPATCH] Task '{title}' [{priority}] -> {best.get('session_name')}@{best.get('platform')}")
        return result

    # Step 2: Check profiles for offline agents
    profile_candidates = find_bots_for_skills(required_skills=required_skills, min_reputation=2.0)
    if profile_candidates:
        best_profile = profile_candidates[0]
        agent_platform = best_profile.get("platform", "claude")
        launch_result = auto_launch_agent(
            platform=agent_platform,
            task_description=f"[{priority.upper()}] {title}: {description}",
            channel=channel,
            name=f"dispatch-{best_profile.get('session_name', agent_platform)}-{uuid.uuid4().hex[:4]}",
        )
        if "error" not in launch_result:
            delegation = delegate_task(
                title=title, description=description,
                required_capability=required_skills[0] if required_skills else "general",
                channel=channel, priority=priority,
            )
            dispatch_time = time.monotonic() - dispatch_start
            result.update({
                "action": "launched_and_delegated", "task_id": delegation.get("task_id"),
                "launched_agent": launch_result.get("name"), "launched_platform": agent_platform,
                "dispatch_time_ms": round(dispatch_time * 1000, 1),
            })
            _log_dispatch_event("dispatched_via_launch", title, result)
            _notify_system(f"[AUTODISPATCH] Task '{title}' [{priority}] -> launched {launch_result.get('name')}@{agent_platform}")
            return result

    # Step 3: Post as unassigned
    from brynq.capability import delegate_task as _delegate
    delegation = _delegate(
        title=title, description=description,
        required_capability=required_skills[0] if required_skills else "general",
        channel=channel, priority=priority,
    )
    dispatch_time = time.monotonic() - dispatch_start
    result.update({
        "action": "posted_unassigned", "task_id": delegation.get("task_id"),
        "reason": "No agent found with matching skills", "dispatch_time_ms": round(dispatch_time * 1000, 1),
    })
    _log_dispatch_event("posted_unassigned", title, result)
    _notify_system(f"[AUTODISPATCH] UNASSIGNED task '{title}' [{priority}] — no agent for: {', '.join(required_skills)}")
    return result


# ── Stalled Task Monitor ─────────────────────────────────────────────────


def monitor_tasks(timeout_minutes: int = 10) -> list:
    """Check for stalled tasks and take corrective action.

    A task is stalled if accepted for longer than timeout_minutes.
    Attempts reassignment, otherwise escalates to #_system.

    Returns:
        List of dicts describing stalled tasks and actions taken.
    """
    from brynq.tasks import list_tasks
    from brynq.capability import find_agents

    now = datetime.now(timezone.utc)
    stalled_report = []
    accepted_tasks = list_tasks(status="accepted")

    for task in accepted_tasks:
        accepted_at_str = task.get("accepted_at")
        if not accepted_at_str:
            continue
        try:
            accepted_at = datetime.fromisoformat(accepted_at_str)
            if accepted_at.tzinfo is None:
                accepted_at = accepted_at.replace(tzinfo=timezone.utc)
        except (ValueError, TypeError):
            continue

        elapsed = (now - accepted_at).total_seconds() / 60.0
        if elapsed < timeout_minutes:
            continue

        task_id = task.get("task_id", "?")
        title = task.get("title", "?")
        assignee = task.get("assignee_name", "unknown")
        required_cap = task.get("required_capability", "")
        action_taken = "escalated"
        reassigned_to = None

        if required_cap:
            alt_agents = find_agents(capability=required_cap)
            current_sid = task.get("assignee_session", "")
            alt_agents = [a for a in alt_agents if a.get("session_id") != current_sid]
            if alt_agents:
                best_alt = alt_agents[0]
                task["assignee_session"] = best_alt.get("session_id")
                task["assignee_name"] = best_alt.get("session_name")
                task["status"] = "delegated"
                task["accepted_at"] = None
                task["reassigned_at"] = datetime.now(timezone.utc).isoformat()
                task["reassign_count"] = task.get("reassign_count", 0) + 1
                _save_task(task)
                action_taken = "reassigned"
                reassigned_to = best_alt.get("session_name")

        _log_dispatch_event(f"task_{action_taken}", title, {
            "task_id": task_id, "stalled_minutes": round(elapsed, 1),
            "assignee": assignee, "reassigned_to": reassigned_to,
        })
        _notify_system(
            f"[AUTODISPATCH] {'Reassigned' if action_taken == 'reassigned' else 'ESCALATION'}: "
            f"Task '{title}' (id: {task_id}) stalled {round(elapsed)}min with {assignee}."
            + (f" -> {reassigned_to}" if reassigned_to else " Manual intervention needed.")
        )
        stalled_report.append({
            "task_id": task_id, "title": title, "assignee": assignee,
            "stalled_minutes": round(elapsed, 1), "action": action_taken, "reassigned_to": reassigned_to,
        })

    return stalled_report


def get_full_dispatch_stats() -> dict:
    """Get comprehensive dispatch statistics from the full event log."""
    events = _read_jsonl_sync(DISPATCH_LOG)
    by_action = {}
    dispatch_times = []
    agent_counts = {}

    for ev in events:
        event_type = ev.get("event", "unknown")
        by_action[event_type] = by_action.get(event_type, 0) + 1
        dt = ev.get("dispatch_time_ms")
        if dt is not None:
            try:
                dispatch_times.append(float(dt))
            except (ValueError, TypeError):
                pass
        assigned = ev.get("assigned_to") or ev.get("launched_agent")
        if assigned:
            agent_counts[assigned] = agent_counts.get(assigned, 0) + 1

    sorted_agents = sorted(agent_counts.items(), key=lambda x: -x[1])
    avg_time = round(sum(dispatch_times) / len(dispatch_times), 1) if dispatch_times else 0.0

    return {
        "total_events": len(events),
        "by_action": by_action,
        "avg_dispatch_time_ms": avg_time,
        "most_used_agents": [{"agent": n, "dispatches": c} for n, c in sorted_agents[:10]],
        "launched_agents": by_action.get("agent_launched", 0),
        "active_launched": len(_load_launched()),
    }
