# tools/task_plan_tools.py — Structured task plan tools (Cursor-like step list).
#
# The plan is scoped to the current conversation ``thread_id`` (resolved via
# :func:`persistence.get_current_agent_thread_id`), persisted encrypted in the
# app DB, and surfaced in the macOS right panel.  Tools validate input through
# :mod:`utils.task_plan` so malformed model output cannot corrupt storage or
# blow up system-prompt injection.
from __future__ import annotations

import json as _json
import logging
from typing import Any

from langchain_core.tools import tool
from pydantic import BaseModel, Field, field_validator

_log = logging.getLogger(__name__)


def _current_thread_id() -> str | None:
    """Resolve the active agent thread id; returns None if not set (rare)."""
    try:
        from persistence import get_current_agent_thread_id
        tid = (get_current_agent_thread_id() or "").strip()
        return tid or None
    except Exception:
        return None


def _format_plan_summary(plan: dict) -> str:
    """Render a short human-readable summary for the model's tool return."""
    from utils.task_plan import (
        STATUS_DONE,
        STATUS_IN_PROGRESS,
        STATUS_SKIPPED,
        STATUS_BLOCKED,
        STATUS_PENDING,
    )
    steps = plan.get("steps") or []
    title = (plan.get("title") or "").strip()
    if not steps:
        return "Task plan is empty."
    lines: list[str] = []
    if title:
        lines.append(f"Plan: {title}")
    for idx, step in enumerate(steps, 1):
        text = (step.get("text") or "").strip()
        status = (step.get("status") or STATUS_PENDING).strip()
        marker = {
            STATUS_DONE: "[x]",
            STATUS_IN_PROGRESS: "[~]",
            STATUS_SKIPPED: "[-]",
            STATUS_BLOCKED: "[!]",
        }.get(status, "[ ]")
        lines.append(f"{idx}. {marker} {text}")
    return "\n".join(lines)


# ─── Tool: update_task_plan ────────────────────────────────────────────────────

class _PlanStepInput(BaseModel):
    id: str = Field(default="", description="Optional stable id; auto-generated if omitted.")
    text: str = Field(..., description="Short step description (<=240 chars).")
    status: str = Field(
        default="pending",
        description="One of: pending, in_progress, done, skipped, blocked.",
    )
    note: str = Field(default="", description="Optional short note (<=400 chars).")


class UpdateTaskPlanInput(BaseModel):
    """Input for update_task_plan."""

    title: str = Field(
        default="",
        description="Optional short plan title (e.g. 'Refactor logging pipeline').",
    )
    steps: Any = Field(
        default_factory=list,
        description=(
            "Ordered list of steps. Each step is an object "
            '{"text": "...", "status": "pending|in_progress|done|skipped|blocked", '
            '"note": "", "id": ""}. Pass all steps you want — this replaces the current plan.'
        ),
    )
    mode: str = Field(
        default="replace",
        description=(
            "How to apply *steps*: 'replace' (default, overwrite the whole plan) or "
            "'merge' (update statuses/notes of steps that share an id with the current plan, "
            "and append any new steps). Use 'replace' when the shape of the plan changes."
        ),
    )

    @field_validator("steps", mode="before")
    @classmethod
    def _coerce_steps(cls, v: Any) -> Any:
        if v is None:
            return []
        if isinstance(v, str):
            s = v.strip()
            if not s:
                return []
            try:
                parsed = _json.loads(s)
            except Exception as exc:
                raise ValueError(f"steps is not valid JSON: {exc}") from exc
            return parsed
        return v

    @field_validator("mode", mode="before")
    @classmethod
    def _coerce_mode(cls, v: Any) -> str:
        return (str(v) if v is not None else "replace").strip().lower() or "replace"


def _merge_plans(current: dict, incoming: dict) -> dict:
    """Apply ``incoming`` on top of ``current``: update by-id, append new steps."""
    out_title = (incoming.get("title") or current.get("title") or "").strip()
    by_id: dict[str, dict] = {}
    order: list[str] = []
    for step in (current.get("steps") or []):
        sid = str(step.get("id") or "")
        if not sid:
            continue
        by_id[sid] = dict(step)
        order.append(sid)
    for step in (incoming.get("steps") or []):
        sid = str(step.get("id") or "")
        if sid and sid in by_id:
            merged = dict(by_id[sid])
            merged.update({k: v for k, v in step.items() if v is not None})
            by_id[sid] = merged
        else:
            new_sid = sid or step.get("id") or ""
            # validate_and_normalize() will assign a uuid if empty; keep step.
            key = new_sid or f"__new_{len(order)}"
            by_id[key] = dict(step)
            order.append(key)
    from utils.task_plan import PLAN_VERSION
    return {
        "version": PLAN_VERSION,
        "title": out_title,
        "steps": [by_id[k] for k in order if k in by_id],
    }


@tool(args_schema=UpdateTaskPlanInput)
def update_task_plan(title: str = "", steps: Any = None, mode: str = "replace") -> str:
    """Record the ordered step list you are working through for this conversation.

    Use this at the start of any multi-step task (refactors, migrations, investigations,
    anything with 3+ real actions) so the plan stays visible in the macOS panel and is
    kept in mind each turn. Update statuses as you go: mark steps in_progress when you
    begin them and done when they are complete. For small, one-shot tasks (quick answers,
    single tool calls), skip the plan — it is not useful for trivial work.

    Statuses: pending, in_progress, done, skipped, blocked.
    mode='replace' (default) overwrites the whole plan; mode='merge' updates existing
    steps by id and appends new ones — prefer 'replace' when the shape of the plan
    changes so stale steps do not linger. A replace with **no steps** is rejected
    when a non-empty plan already exists (accidental `{}` from the model); use
    ``clear_task_plan(confirm=true)`` to remove the plan intentionally.
    """
    tid = _current_thread_id()
    if not tid:
        return "No active conversation thread; cannot update task plan."
    steps_value = steps if steps is not None else []
    incoming_raw = {"title": title, "steps": steps_value}
    try:
        from utils.task_plan import validate_and_normalize, notify_task_plan_changed
        normalized_incoming = validate_and_normalize(incoming_raw)
    except ValueError as exc:
        return f"Task plan rejected: {exc}"
    mode_l = (mode or "replace").strip().lower()
    if mode_l == "merge":
        try:
            from chat_storage import ensure_encryption_ready, get_task_plan
            ensure_encryption_ready()
            current = get_task_plan(tid) or {}
        except Exception:
            current = {}
        merged = _merge_plans(current, normalized_incoming)
        try:
            plan = validate_and_normalize(merged)
        except ValueError as exc:
            return f"Task plan rejected after merge: {exc}"
    else:
        # Replacing an in-thread plan with an empty step list (e.g. `{}` or
        # `{"steps":[]}` from a malformed XML tool call) wipes the whole plan.
        # Local models do this often; it then disables completion enforcement
        # and empties the UI. Intentional clear must go through
        # clear_task_plan(confirm=true).
        incoming_steps = normalized_incoming.get("steps") or []
        if not incoming_steps:
            try:
                from chat_storage import ensure_encryption_ready, get_task_plan
                ensure_encryption_ready()
                existing = get_task_plan(tid) or {}
            except Exception:
                existing = {}
            if existing.get("steps"):
                return (
                    "Task plan not saved: replacing with an empty step list would "
                    "erase the current plan. If you meant to remove the plan entirely, "
                    "call clear_task_plan(confirm=true). If you meant to update step "
                    "statuses without rewriting the list, use mode='merge' with the "
                    "relevant step ids. Otherwise pass the full steps array for a "
                    "replace."
                )
        plan = normalized_incoming
    _coord = False
    try:
        from utils.kanban_coordinator import probe_board_active

        _coord = probe_board_active(tid)
    except Exception:
        _log.warning("coordinator board probe failed during plan save", exc_info=True)
        return "Task plan rejected: could not verify coordinator board state."
    try:
        from utils.task_plan_evidence import validate_plan_completion

        _ok, _err = validate_plan_completion(tid, plan, coordinator_mode=_coord)
        if not _ok:
            return f"Task plan rejected: {_err}"
    except ValueError as exc:
        return f"Task plan rejected: {exc}"
    except Exception:
        _log.warning("task plan evidence gate unavailable", exc_info=True)
        return "Task plan rejected: evidence gate unavailable."
    try:
        from chat_storage import ensure_encryption_ready, set_task_plan
        ensure_encryption_ready()
        set_task_plan(tid, plan)
    except ValueError as exc:
        return f"Task plan rejected: {exc}"
    except Exception as exc:
        _log.warning("set_task_plan failed", exc_info=True)
        return f"Failed to save task plan: {exc}"
    try:
        notify_task_plan_changed(tid)
    except Exception:
        _log.debug("notify_task_plan_changed failed", exc_info=True)
    summary = _format_plan_summary(plan)
    return f"Task plan updated.\n{summary}"


# ─── Tool: clear_task_plan ─────────────────────────────────────────────────────

class ClearTaskPlanInput(BaseModel):
    """Input for clear_task_plan."""

    confirm: bool = Field(
        default=True,
        description="Must be true to confirm clearing the plan (prevents accidental calls).",
    )


@tool(args_schema=ClearTaskPlanInput)
def clear_task_plan(confirm: bool = True) -> str:
    """Delete the task plan for the current conversation.

    Use when abandoning a task, finishing all steps, or when the scope has changed
    so the current plan is no longer accurate. Call update_task_plan with fresh
    steps afterwards if you are starting a new task.
    """
    tid = _current_thread_id()
    if not tid:
        return "No active conversation thread; nothing to clear."
    if not confirm:
        return "Set confirm=true to clear the plan."
    try:
        from utils.kanban_coordinator import probe_board_active

        if probe_board_active(tid):
            return (
                "Task plan not cleared: Kanban coordinator board is still running for this "
                "conversation. Wait for workers to finish or cancel tasks on the board first."
            )
    except Exception:
        _log.warning("coordinator board probe failed during plan clear", exc_info=True)
        return "Task plan not cleared: could not verify coordinator board state."
    try:
        from chat_storage import ensure_encryption_ready, delete_task_plan
        from utils.task_plan import notify_task_plan_changed
        ensure_encryption_ready()
        existed = delete_task_plan(tid)
    except Exception as exc:
        _log.warning("delete_task_plan failed", exc_info=True)
        return f"Failed to clear task plan: {exc}"
    try:
        notify_task_plan_changed(tid, cleared=True)
    except Exception:
        _log.debug("notify_task_plan_changed failed", exc_info=True)
    return "Task plan cleared." if existed else "No task plan to clear."


# ─── Tool: get_task_plan ───────────────────────────────────────────────────────

class GetTaskPlanInput(BaseModel):
    """Input for get_task_plan (no parameters)."""


@tool(args_schema=GetTaskPlanInput)
def get_task_plan() -> str:
    """Read back the current task plan for this conversation, or report 'empty'."""
    tid = _current_thread_id()
    if not tid:
        return "No active conversation thread."
    try:
        from chat_storage import ensure_encryption_ready, get_task_plan as _get
        ensure_encryption_ready()
        plan = _get(tid)
    except Exception as exc:
        return f"Failed to read task plan: {exc}"
    if not plan or not (plan.get("steps") or []):
        return "Task plan is empty."
    return _format_plan_summary(plan)
