"""Auto-extracted from core/tool_registry.py — see core/handlers/__init__.py"""

import json
import logging
import re
import threading
import time
from typing import Dict, Any, List, Optional

from core.task_lifecycle import cleanup_agent_task_context
from core.tool_handler import ToolHandler

logger = logging.getLogger(__name__)


def wake_agent_poller() -> None:
    """Wake the live agent poller if it is running."""
    try:
        from tasks.ai.agent_loop import AgentLoopTask
        _exec = AgentLoopTask._live_instance
        if _exec is not None and hasattr(_exec, "_poller_wake"):
            _exec._poller_wake.set()
    except Exception:
        logger.debug("agent poller wake failed", exc_info=True)


def schedule_agent_task_wake(conversation_id: str, task_id: str,
                             reason: str, user_id: str = "",
                             delay_seconds: int = 0) -> float:
    """Schedule a task wake and nudge the poller immediately."""
    from core.poll_scheduler import PollScheduler
    recheck_at = PollScheduler.instance().schedule_delay(
        conversation_id, delay_seconds,
        key=f"{conversation_id}::task::{task_id}",
        reason=reason,
        user_id=user_id,
    )
    wake_agent_poller()
    return recheck_at


def _activate_dependents(conversation_id: str, completed_task_id: str,
                        result: str = "", user_id: str = ""):
    """Check if any waiting tasks can be activated after a task completes."""
    from core.conversation_store import ConversationStore
    store = ConversationStore.instance()
    all_tasks = store.get_extra(conversation_id, "agent_tasks") or {}
    activated = []
    for tid, t in list(all_tasks.items()):
        if not isinstance(t, dict) or t.get("status") != "waiting":
            continue
        deps = t.get("depends_on") or []
        if completed_task_id not in deps:
            continue
        # Check if ALL deps are met (not in all_tasks = completed and removed)
        all_met = all(d not in all_tasks or all_tasks[d].get("status") == "completed"
                      for d in deps)
        if not all_met:
            continue
        # Activate this task
        t["status"] = "active"
        all_tasks[tid] = t
        schedule_agent_task_wake(
            conversation_id, tid,
            reason=f"[agent_task:{tid}] deps met, activated ({t.get('agent', '?')})",
            user_id=user_id or t.get("assigned_by", ""),
            delay_seconds=0,
        )
        # Inject parent results into the sub-conversation
        parent_results = {}
        for d in deps:
            _log = store.get_extra(conversation_id, f"task_log:{d}") or []
            # Find last completed entry
            for entry in reversed(_log):
                if entry.get("type") == "completed":
                    parent_results[d] = entry.get("detail", "")
                    break
        if parent_results:
            sub_cid = f"{conversation_id}::task::{tid}"
            _msg = "## Results from dependency tasks\n"
            for dep_id, dep_result in parent_results.items():
                _msg += f"\n### Task {dep_id}\n{dep_result}\n"
            import uuid as _task_uuid
            from core.conversation_writer import ConversationWriter
            from core.llm_client import stamp_message
            ConversationWriter.for_conversation(sub_cid).enqueue_message(
                stamp_message({"role": "user", "content": _msg,
                               "msg_id": _task_uuid.uuid4().hex[:12]},
                              sub_cid))
        activated.append(tid)
        try:
            _append_task_log(conversation_id, tid, {
                "type": "activated",
                "agent": t.get("agent", ""),
                "detail": f"Dependencies met: {', '.join(deps)}",
            })
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)
        try:
            from core.conversation_event_bus import ConversationEventBus
            ConversationEventBus.instance().publish_event(
                conversation_id, "task_progress", {
                    "task_id": tid, "agent": t.get("agent", ""),
                    "stage": "activated", "deps_met": deps,
                },
            )
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)
    if activated:
        store.set_extra(conversation_id, "agent_tasks", all_tasks)
        logger.info("Activated %d dependent tasks after %s completed: %s",
                    len(activated), completed_task_id, activated)
    return activated


def _append_task_log(conversation_id: str, task_id: str, entry: dict):
    """Append an entry to the persistent task timeline log (standalone helper)."""
    import time
    from core.conversation_store import ConversationStore
    store = ConversationStore.instance()
    key = f"task_log:{task_id}"
    log = store.get_extra(conversation_id, key) or []
    entry["ts"] = time.time()
    log.append(entry)
    if len(log) > 500:
        log = log[-500:]
    store.set_extra(conversation_id, key, log)





class LinkResourceHandler(ToolHandler):
    """Link or unlink repository resources to the current conversation.

    Only relays are linkable. Everything else is auto-available when
    accessible in scope (global + user + conversation).
    """

    def __init__(self):
        self._conversation_id = ""
        self._user_id = ""

    @property
    def name(self) -> str:
        return "link_resource"

    @property
    def description(self) -> str:
        return (
            "Link or unlink a relay to this conversation.\n"
            "Only relays are linkable (set_default, per-agent binding via /relay).\n"
            "MCPs/skills/tasks/agents are auto-available when in scope.\n\n"
            "Actions: link, unlink, list"
        )

    @property
    def parameters_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["link", "unlink", "list"],
                },
                "resource_type": {
                    "type": "string",
                    "enum": ["relays"],
                },
                "name": {
                    "type": "string",
                    "description": "Resource name (for link/unlink)",
                },
            },
            "required": ["action", "resource_type"],
        }

    def set_conversation_id(self, cid: str):
        self._conversation_id = cid

    def set_user_id(self, uid: str):
        self._user_id = uid

    def execute(self, arguments: Dict[str, Any]) -> str:
        from core.conv_links import get_linked, link, unlink
        action = arguments.get("action", "")
        rtype = arguments.get("resource_type", "")
        name = arguments.get("name", "")

        if not self._conversation_id:
            return "Error: no conversation context"

        if action == "list":
            linked = get_linked(self._conversation_id, rtype)
            if not linked:
                return f"No {rtype} linked to this conversation."
            return f"Linked {rtype}: " + ", ".join(linked)

        if not name:
            return "Error: name is required for link/unlink"

        if action == "link":
            link(self._conversation_id, rtype, name)
            return f"{rtype[:-1].title()} '{name}' linked to this conversation."

        if action == "unlink":
            unlink(self._conversation_id, rtype, name)
            return f"{rtype[:-1].title()} '{name}' unlinked from this conversation."

        return f"Unknown action: {action}"


# Keep old name as alias for imports
LinkTaskHandler = LinkResourceHandler


class AssignTaskHandler(ToolHandler):
    """Assign a task to an agent (self or another agent)."""

    def __init__(self):
        self._conversation_id = ""
        self._agent_name = ""
        self._user_id = ""

    @property
    def name(self) -> str:
        return "assign_task"

    @property
    def description(self) -> str:
        return (
            "Assign a task to yourself or another agent. The assigned agent "
            "will work on it autonomously, rescheduling at regular intervals "
            "until the task is complete."
        )

    @property
    def parameters_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "agent": {
                    "type": "string",
                    "description": "Agent to assign the task to (name, or 'self' for yourself)",
                },
                "task_def_name": {
                    "type": "string",
                    "description": "Name of the task definition to run. Must exist (conversation, user, or global scope).",
                },
                "completion_criteria": {
                    "type": "string",
                    "description": "Override completion criteria from the task definition",
                },
                "interval": {
                    "type": "string",
                    "description": "Schedule frequency. Examples: '60' (every 60s), '3/5m' (3 times per 5min), '2-4/h' (2-4 per hour). Default: 6/1m (same as autoconv)",
                },
                "max_iterations": {
                    "type": "integer",
                    "description": "Max work sessions before auto-fail (default: 50)",
                },
                "verifier": {
                    "type": "string",
                    "description": "Agent that verifies completion (optional)",
                },
                "variables": {
                    "type": "object",
                    "description": "Variables to substitute in prompt/criteria. E.g. {\"nbr_images\": \"20\"} replaces ${nbr_images} in the task definition. Use \\${...} in definitions to keep literal ${...} unresolved.",
                },
                "context": {
                    "type": "string",
                    "description": "Context mode: 'isolated' (default), 'last:N' (last N messages), 'summary:N' (summary of N tokens), 'full' (entire parent context)",
                },
                "timeout": {
                    "type": "string",
                    "description": "DEPRECATED — use max_turn_time instead.",
                },
                "max_turn_time": {
                    "type": "string",
                    "description": "Max duration per work session. Examples: '300' (300s), '5m', '1h'. If exceeded the turn is interrupted (not cancelled) and rescheduled normally. Default: no limit.",
                },
                "max_budget": {
                    "type": "string",
                    "description": "Max total cost for this task. Examples: '0.50', '$2', '1.00'. Task is cancelled if cumulative cost exceeds this. Default: no limit.",
                },
                "max_total_time": {
                    "type": "string",
                    "description": "Max total elapsed time across all reschedules. Examples: '30m', '1h', '2h'. Task is cancelled if exceeded. Default: no limit.",
                },
                "max_reschedules": {
                    "type": "integer",
                    "description": "Max number of reschedules (work sessions) before the task is cancelled. Default: no limit (0).",
                },
                "auto_allow": {
                    "type": "boolean",
                    "description": "Auto-approve all tool calls and commands for this task (no permission prompts). Default: false.",
                },
                "depends_on": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of task IDs that must complete before this task starts. The task will be in 'waiting' status until all dependencies are met.",
                },
            },
            "required": ["agent", "task_def_name"],
        }

    @staticmethod
    def _has_cycle(all_tasks: dict, new_id: str, new_deps: list) -> bool:
        """Check if adding new_id with new_deps would create a cycle."""
        # Build adjacency: task → its depends_on
        graph = {}
        for tid, t in all_tasks.items():
            if isinstance(t, dict):
                graph[tid] = t.get("depends_on") or []
        graph[new_id] = new_deps
        # DFS cycle detection
        visited = set()
        in_stack = set()
        def _dfs(node):
            if node in in_stack:
                return True
            if node in visited:
                return False
            visited.add(node)
            in_stack.add(node)
            for dep in graph.get(node, []):
                if _dfs(dep):
                    return True
            in_stack.discard(node)
            return False
        return _dfs(new_id)

    @staticmethod
    def _parse_interval(spec: str, fallback: int = 10) -> dict:
        """Parse interval spec → {min: seconds, max: seconds, spec: original}.

        Formats:
          '60'       → fixed 60s
          '3/5m'     → 3 times per 5 minutes
          '2-4/h'    → 2-4 times per hour
        """
        import re
        spec = spec.strip()
        # Plain seconds
        try:
            secs = int(spec)
            return {"min": secs, "max": secs, "spec": spec}
        except ValueError:
            pass
        # Frequency spec: count[-count]/[num]unit
        m = re.match(r'^(\d+)(?:-(\d+))?/(\d*)([smhd])$', spec)
        if not m:
            return {"min": fallback, "max": fallback, "spec": spec}
        count_min = int(m.group(1))
        count_max = int(m.group(2) or count_min)
        if count_min <= 0 or count_max < count_min:
            return {"min": fallback, "max": fallback, "spec": spec}
        duration_num = int(m.group(3) or 1)
        unit = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}[m.group(4)]
        period = duration_num * unit
        max_interval = period // count_min
        min_interval = period // count_max
        return {"min": max(1, min_interval), "max": max(1, max_interval), "spec": spec}

    @staticmethod
    def _get_task_delay(task_data: dict) -> int:
        """Get the next delay in seconds from a task's interval config."""
        import random
        iv = task_data.get("interval", {})
        if isinstance(iv, int):
            return iv
        if isinstance(iv, dict):
            return random.randint(iv.get("min", 60), iv.get("max", 60))  # nosec B311
        return 60

    @staticmethod
    def _parse_timeout(spec: str) -> int:
        """Parse timeout spec → seconds. Returns 0 for no timeout.

        Formats: '300' (seconds), '5m', '1h', '2h30m'
        """
        if not spec:
            return 0
        import re
        spec = spec.strip()
        try:
            return int(spec)
        except ValueError:
            pass
        total = 0
        for m in re.finditer(r'(\d+)([smhd])', spec):
            val = int(m.group(1))
            unit = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}[m.group(2)]
            total += val * unit
        return total

    @staticmethod
    def _resolve_task_vars(text: str, variables: dict, user_id: str = "") -> str:
        """Resolve variables in task prompt/criteria.

        Resolution order:
        1. Escaped \\${...} → preserved as literal ${...}
        2. Custom variables from 'variables' dict: ${key} → value
        3. Unified cascade: secrets → params → env
        """
        # Step 1: protect escaped \${...} with placeholder
        _esc = "\x00ESC\x00"
        text = text.replace("\\${", _esc)
        # Step 2: replace custom variables ${key}
        if variables:
            for key, val in variables.items():
                text = text.replace(f"${{{key}}}", str(val))
        # Step 3: resolve remaining ${key} via unified cascade
        if "${" in text:
            from core.expression import resolve_expression
            text = resolve_expression(text, owner=user_id)
        # Step 4: restore escaped expressions
        text = text.replace(_esc, "${")
        return text

    def set_conversation_id(self, cid: str):
        self._conversation_id = cid

    def set_agent_name(self, name: str):
        self._agent_name = name

    def set_user_id(self, uid: str):
        self._user_id = uid

    def execute(self, arguments: Dict[str, Any]) -> str:
        import time as _t
        target = arguments.get("agent", "")
        if target == "self":
            target = self._agent_name or ""
        task_def_name = arguments.get("task_def_name", "")

        if not task_def_name:
            return "Error: task_def_name is required. Create a task definition first."
        if not self._conversation_id:
            return "Error: no conversation context"

        # Resolve task_def_name → prompt + criteria + interval
        from core.resource_store import ResourceStore
        rs = ResourceStore.instance()
        definition = rs.get_any("task_def", task_def_name, self._user_id,
                                 conversation_id=self._conversation_id)
        if not definition:
            return f"Error: task definition '{task_def_name}' not found"
        task_desc = definition.get("prompt", "")
        if not task_desc:
            return f"Error: task definition '{task_def_name}' has no prompt"
        if not arguments.get("completion_criteria"):
            arguments["completion_criteria"] = definition.get("criteria", "")
        if not arguments.get("interval"):
            arguments["interval"] = definition.get("default_interval", "6/1m")

        # Variable substitution in prompt and criteria
        _vars = arguments.get("variables") or {}
        if _vars or "${" in task_desc:
            task_desc = self._resolve_task_vars(task_desc, _vars, self._user_id)
        criteria = arguments.get("completion_criteria", "")
        if criteria and (_vars or "${" in criteria):
            criteria = self._resolve_task_vars(criteria, _vars, self._user_id)
        _raw_iv = arguments.get("interval")
        interval_spec = str(_raw_iv) if _raw_iv else "6/1m"
        max_iter = int(arguments.get("max_iterations", 0))
        verifier = arguments.get("verifier", "") or definition.get("verifier", "")
        # max_turn_time supersedes deprecated timeout
        _turn_time_raw = arguments.get("max_turn_time", "") or arguments.get("timeout", "")
        timeout_secs = self._parse_timeout(_turn_time_raw)
        # New limit params
        _max_budget_raw = str(arguments.get("max_budget", "") or "").strip().lstrip("$")
        max_budget = float(_max_budget_raw) if _max_budget_raw else 0.0
        max_total_time = self._parse_timeout(arguments.get("max_total_time", "") or "")
        max_reschedules = int(arguments.get("max_reschedules", 0) or 0)
        auto_allow = bool(arguments.get("auto_allow", False))
        # Also check task definition for auto_allow default
        if not auto_allow and definition.get("auto_allow"):
            auto_allow = True
        interactive = bool(arguments.get("interactive", False) or definition.get("interactive"))

        # Parse interval: plain seconds or frequency spec (3/5m, 2-4/h)
        interval_data = self._parse_interval(interval_spec)

        import uuid as _uuid
        task_id = "t_" + _uuid.uuid4().hex[:8]

        from core.conversation_store import ConversationStore
        store = ConversationStore.instance()

        # Store in agent_tasks dict (multiple tasks per agent)
        all_tasks = store.get_extra(self._conversation_id, "agent_tasks") or {}

        # Dependency handling
        from core.handlers._arg_normalize import normalize_string_list
        depends_on = normalize_string_list(arguments.get("depends_on"))
        if depends_on:
            # Validate: all deps must exist in the same conversation
            for dep_id in depends_on:
                if dep_id not in all_tasks:
                    return f"Error: dependency '{dep_id}' not found in this conversation's tasks"
            # Cycle detection: DFS from this task through depends_on graph
            if self._has_cycle(all_tasks, task_id, depends_on):
                return "Error: circular dependency detected"

        context_mode = arguments.get("context", "isolated")
        # Determine initial status based on dependencies
        _deps_met = not depends_on or all(
            all_tasks.get(d, {}).get("status") in (None, "completed")
            or d not in all_tasks
            for d in depends_on
        )
        _initial_status = "active" if _deps_met else "waiting"
        task_data = {
            "task_id": task_id,
            "agent": target,
            "task": task_desc,
            "completion_criteria": criteria,
            "status": _initial_status,
            "interval": interval_data,
            "max_iterations": max_iter,
            "iterations_done": 0,
            "verifier": verifier,
            "assigned_by": self._agent_name or self._user_id or "unknown",
            "created_by": self._agent_name or self._user_id or "unknown",
            "task_def_name": task_def_name,
            "created_at": _t.time(),
            "last_result": "",
            "context_mode": context_mode,
            "timeout": timeout_secs,
            "max_budget": max_budget,
            "max_total_time": max_total_time,
            "max_reschedules": max_reschedules,
            "total_cost": 0.0,
            "reschedule_count": 0,
            "auto_allow": auto_allow,
            "interactive": interactive,
            "skills": self._merge_task_skills(
                definition.get("skills") or [],
                arguments.get("skills") or [],
                target, self._conversation_id),
            "depends_on": depends_on,
        }
        all_tasks[task_id] = task_data
        store.set_extra(self._conversation_id, "agent_tasks", all_tasks)

        # Schedule first wake-up (only if not waiting on deps)
        if _initial_status == "active":
            first_delay = 0
            schedule_agent_task_wake(
                self._conversation_id, task_id,
                reason=f"[agent_task:{task_id}] assigned task ({target})",
                user_id=self._user_id,
                delay_seconds=0,
            )
        else:
            first_delay = 0

        # Publish SSE
        try:
            from core.conversation_event_bus import ConversationEventBus
            ConversationEventBus.instance().publish_event(
                self._conversation_id, "task_progress", {
                    "task_id": task_id, "agent": target, "stage": "assigned",
                    "task": task_desc[:200], "verifier": verifier,
                    "assigned_by": self._agent_name or "user",
                },
            )
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

        try:
            _append_task_log(self._conversation_id, task_id, {
                "type": "assigned",
                "agent": target,
                "task": task_desc[:200],
                "detail": f"Assigned by {self._agent_name or 'user'}, verifier={verifier or 'none'}",
            })
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

        v_info = f" (verifier: {verifier})" if verifier else ""
        iv_label = interval_data.get("spec", str(first_delay))
        dep_info = f" Waiting on: {', '.join(depends_on)}." if _initial_status == "waiting" else ""
        sched_info = " Starting now." if _initial_status == "active" else ""
        return f"Task {task_id} assigned to '{target}'{v_info}. Status: {_initial_status}. Interval: {iv_label}.{sched_info}{dep_info}"


    @staticmethod
    def _merge_task_skills(def_skills, explicit_skills, agent_name, conv_id):
        """Merge skills: agent's conv skills (inherited) + task_def skills + explicit.

        Deduplicates by name, preserving order.
        """
        seen = set()
        merged = []
        # 1. Agent's conv-level skills (inherited baseline)
        if conv_id and agent_name:
            try:
                from core.conv_agent_config import get_agent_config
                agent_skills = get_agent_config(conv_id, agent_name).get("skills") or []
                for s in agent_skills:
                    n = s if isinstance(s, str) else s.get("name", "")
                    if n and n not in seen:
                        seen.add(n)
                        merged.append(s)
            except Exception:
                logging.getLogger(__name__).debug("Ignored exception", exc_info=True)
        # 2. Task definition skills
        for s in (def_skills or []):
            n = s if isinstance(s, str) else s.get("name", "")
            if n and n not in seen:
                seen.add(n)
                merged.append(s)
        # 3. Explicitly passed skills (override/additions)
        for s in (explicit_skills or []):
            n = s if isinstance(s, str) else s.get("name", "")
            if n and n not in seen:
                seen.add(n)
                merged.append(s)
        return merged


class CompleteTaskHandler(ToolHandler):
    """Report progress or completion of an assigned task.

    Called by the agent at each wake-up to update task status.
    If done=true and a verifier agent is assigned, triggers verification.
    """

    def __init__(self):
        self._conversation_id = ""
        self._agent_name = ""

    @property
    def name(self) -> str:
        return "complete_task"

    @property
    def description(self) -> str:
        return (
            "Report progress or completion of your assigned task. "
            "Call this at each iteration to update your progress. "
            "Set done=true when the task is finished."
        )

    @property
    def parameters_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "task_id": {
                    "type": "string",
                    "description": "Task ID to update (optional if you have only one active task)",
                },
                "done": {
                    "type": "boolean",
                    "description": "True if the task is complete, false if still in progress",
                },
                "progress": {
                    "type": "string",
                    "description": "Status update (e.g. '30/100 posts scraped')",
                },
                "result": {
                    "type": "string",
                    "description": "Final result summary (only when done=true)",
                },
            },
            "required": ["done", "progress"],
        }

    def set_conversation_id(self, cid: str):
        self._conversation_id = cid

    def set_agent_name(self, name: str):
        self._agent_name = name

    def execute(self, arguments: Dict[str, Any]) -> str:
        import time as _t
        task_id = arguments.get("task_id", "")
        done = arguments.get("done", False)
        progress = arguments.get("progress", "")
        result = arguments.get("result", "")

        if not self._conversation_id:
            return "Error: no conversation context"

        agent = self._agent_name or ""
        from core.conversation_store import ConversationStore
        store = ConversationStore.instance()
        # Tasks are stored in the PARENT conversation's extras.
        # Sub-conv IDs have format: parent_id::task::task_id
        _parent_cid = self._conversation_id
        if "::task::" in _parent_cid:
            _parent_cid = _parent_cid.split("::task::")[0]
        all_tasks = store.get_extra(_parent_cid, "agent_tasks") or {}

        # Find the task — by ID or by agent (if only one active)
        task = None
        if task_id:
            task = all_tasks.get(task_id)
        else:
            # Find active tasks for this agent
            my_tasks = [t for t in all_tasks.values()
                        if t.get("agent") == agent and t.get("status") in ("active",)]
            if len(my_tasks) == 1:
                task = my_tasks[0]
                task_id = task["task_id"]
            elif len(my_tasks) > 1:
                ids = [t["task_id"] for t in my_tasks]
                return f"Multiple active tasks. Specify task_id: {', '.join(ids)}"

        if not task or task.get("status") not in ("active", "verifying"):
            return "No active task found."

        task["iterations_done"] = task.get("iterations_done", 0) + 1
        task["last_result"] = result if done else progress
        task["last_update"] = _t.time()

        try:
            from core.conversation_event_bus import ConversationEventBus
            _task_iteration = task.get("reschedule_count", task["iterations_done"])
            ConversationEventBus.instance().publish_event(
                _parent_cid, "task_progress", {
                    "task_id": task_id, "agent": agent, "done": done,
                    "progress": progress, "result": result,
                    "iterations": task["iterations_done"],
                    "task_iteration": _task_iteration,
                },
            )
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

        try:
            _log_type = "completed" if done else "progress"
            _log_detail = result[:200] if done else progress[:200]
            _append_task_log(_parent_cid, task_id, {
                "type": _log_type,
                "agent": agent,
                "detail": _log_detail,
            })
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

        if done:
            # Don't touch cancelled/paused tasks — user cancelled intentionally
            if task.get("status") in ("cancelled", "paused"):
                return f"Task {task_id} was {task['status']} — ignoring completion."
            # Recurring tasks (no criteria) cannot be completed — ignore done=true
            if not task.get("completion_criteria"):
                # Check limits before rescheduling
                from tasks.ai.agent_poller import _check_task_limits
                _cancel_reason = _check_task_limits(task, task_id)
                if _cancel_reason:
                    task["status"] = "cancelled"
                    task["cancel_reason"] = _cancel_reason
                    all_tasks[task_id] = task
                    store.set_extra(_parent_cid, "agent_tasks", all_tasks)
                    from core.poll_scheduler import PollScheduler
                    PollScheduler.instance().cancel(f"{_parent_cid}::task::{task_id}")
                    cleanup_agent_task_context(
                        _parent_cid, task_id, task.get("agent", agent), store,
                        clear_runtime=True, reason="task_limit_cancel")
                    return f"Task {task_id} cancelled: {_cancel_reason}"
                task["status"] = "active"
                all_tasks[task_id] = task
                store.set_extra(_parent_cid, "agent_tasks", all_tasks)
                delay = AssignTaskHandler._get_task_delay(task)
                from core.poll_scheduler import PollScheduler
                PollScheduler.instance().schedule_delay(
                    _parent_cid, delay,
                    key=f"{_parent_cid}::task::{task_id}",
                    reason=f"[agent_task:{task_id}] recurring ({task.get('agent', agent)})",
                    user_id=task.get("assigned_by", ""),
                )
                return f"Task {task_id} is recurring (no completion criteria). Progress noted. Next in {delay}s."
            verifier = task.get("verifier", "")
            if verifier:
                task["status"] = "verifying"
                all_tasks[task_id] = task
                store.set_extra(_parent_cid, "agent_tasks", all_tasks)
                from core.poll_scheduler import PollScheduler
                PollScheduler.instance().schedule_delay(
                    _parent_cid, 0,
                    key=f"{_parent_cid}::task_verify::{task_id}",
                    reason=f"[task_verify:{task_id}] verify by {verifier} ({agent})",
                    user_id=task.get("assigned_by", ""),
                )
                return f"Task {task_id} marked done. Verifier '{verifier}' will check."
            else:
                # Remove completed task — trace is in chat history
                all_tasks.pop(task_id, None)
                store.set_extra(_parent_cid, "agent_tasks", all_tasks)
                # Cancel any pending schedule
                from core.poll_scheduler import PollScheduler
                PollScheduler.instance().cancel(
                    f"{_parent_cid}::task::{task_id}")
                cleanup_agent_task_context(
                    _parent_cid, task_id, task.get("agent", agent), store,
                    reason="task_completed")
                # Activate dependent tasks
                _activated = _activate_dependents(
                    _parent_cid, task_id, result=result,
                    user_id=task.get("assigned_by", ""))
                _act_msg = f" Activated: {', '.join(_activated)}." if _activated else ""
                return f"Task {task_id} completed.{_act_msg}"
        else:
            # Check limits before rescheduling
            from tasks.ai.agent_poller import _check_task_limits
            _cancel_reason = _check_task_limits(task, task_id)
            if _cancel_reason:
                task["status"] = "cancelled"
                task["cancel_reason"] = _cancel_reason
                all_tasks[task_id] = task
                store.set_extra(_parent_cid, "agent_tasks", all_tasks)
                from core.poll_scheduler import PollScheduler
                PollScheduler.instance().cancel(f"{_parent_cid}::task::{task_id}")
                cleanup_agent_task_context(
                    _parent_cid, task_id, task.get("agent", agent), store,
                    clear_runtime=True, reason="task_limit_cancel")
                try:
                    from core.conversation_event_bus import ConversationEventBus
                    ConversationEventBus.instance().publish_event(
                        _parent_cid, "task_stopped", {
                            "task_id": task_id, "agent_name": agent,
                            "reason": _cancel_reason, "force": True})
                except Exception:
                    logging.getLogger(__name__).debug("Ignored exception", exc_info=True)
                return f"Task {task_id} cancelled: {_cancel_reason}"
            task["status"] = "active"
            all_tasks[task_id] = task
            store.set_extra(_parent_cid, "agent_tasks", all_tasks)
            delay = AssignTaskHandler._get_task_delay(task)
            from core.poll_scheduler import PollScheduler
            PollScheduler.instance().schedule_delay(
                _parent_cid, delay,
                key=f"{_parent_cid}::task::{task_id}",
                reason=f"[agent_task:{task_id}] continue ({task.get('agent', agent)})",
                user_id=task.get("assigned_by", ""),
            )
            return f"Task {task_id} progress noted. Next in {delay}s."


class VerifyTaskHandler(ToolHandler):
    """Approve or reject a completed task (used by verifier agents)."""

    def __init__(self):
        self._conversation_id = ""
        self._agent_name = ""

    @property
    def name(self) -> str:
        return "verify_task"

    @property
    def description(self) -> str:
        return (
            "Approve or reject a task that another agent claims to have completed. "
            "You are the verifier — check the result against the criteria."
        )

    @property
    def parameters_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "task_id": {
                    "type": "string",
                    "description": "Task ID to verify",
                },
                "approved": {
                    "type": "boolean",
                    "description": "True if the task is satisfactorily completed",
                },
                "reason": {
                    "type": "string",
                    "description": "Explanation (required if rejecting)",
                },
            },
            "required": ["task_id", "approved"],
        }

    def set_conversation_id(self, cid: str):
        self._conversation_id = cid

    def set_agent_name(self, name: str):
        self._agent_name = name

    def execute(self, arguments: Dict[str, Any]) -> str:
        import time as _t
        task_id = arguments.get("task_id", "")
        approved = arguments.get("approved", False)
        reason = arguments.get("reason", "")

        if not self._conversation_id or not task_id:
            return "Error: missing conversation or task_id"

        from core.conversation_store import ConversationStore
        store = ConversationStore.instance()
        # Tasks are stored in the PARENT conv (sub-conv format: parent::task::tid)
        _parent_cid = self._conversation_id
        if "::task" in _parent_cid:
            _parent_cid = _parent_cid.split("::task")[0]
        all_tasks = store.get_extra(_parent_cid, "agent_tasks") or {}
        task = all_tasks.get(task_id)
        if not task:
            return f"Task '{task_id}' not found"
        target_agent = task.get("agent", "?")

        try:
            from core.conversation_event_bus import ConversationEventBus
            ConversationEventBus.instance().publish_event(
                _parent_cid, "task_progress", {
                    "task_id": task_id, "agent": target_agent,
                    "verifier": self._agent_name,
                    "approved": approved, "reason": reason,
                    "stage": "verified",
                },
            )
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

        try:
            _append_task_log(_parent_cid, task_id, {
                "type": "verified",
                "agent": target_agent,
                "verifier": self._agent_name,
                "approved": approved,
                "detail": reason[:200] if reason else ("approved" if approved else "rejected"),
            })
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

        if approved:
            # Remove completed task
            _result = task.get("last_result", "")
            all_tasks.pop(task_id, None)
            store.set_extra(_parent_cid, "agent_tasks", all_tasks)
            from core.poll_scheduler import PollScheduler
            PollScheduler.instance().cancel(
                f"{_parent_cid}::task::{task_id}")
            PollScheduler.instance().cancel(
                f"{_parent_cid}::task_verify::{task_id}")
            cleanup_agent_task_context(
                _parent_cid, task_id, target_agent, store,
                reason="task_verified")
            # Activate dependent tasks
            _activated = _activate_dependents(
                _parent_cid, task_id, result=_result,
                user_id=task.get("assigned_by", ""))
            _act_msg = f" Activated: {', '.join(_activated)}." if _activated else ""
            return f"Task {task_id} approved and completed.{_act_msg}"
        else:
            task["status"] = "active"
            task["last_rejection"] = {
                "by": self._agent_name, "reason": reason, "at": _t.time(),
            }
            all_tasks[task_id] = task
            store.set_extra(_parent_cid, "agent_tasks", all_tasks)
            from core.poll_scheduler import PollScheduler
            PollScheduler.instance().schedule_delay(
                _parent_cid, 0,
                key=f"{_parent_cid}::task::{task_id}",
                reason=f"[agent_task:{task_id}] rejected: {reason[:80]} ({target_agent})",
                user_id=task.get("assigned_by", ""),
            )
            return f"Task {task_id} rejected. Agent '{target_agent}' rescheduled."
