"""Plan / todo state — pure stdlib, no UI framework dependency.

API:
  extract(text)                   → [(content, "open"|"done"), …]
  is_active(agent, messages=None) → plan mode on (stash OR per-session msg ref)
  resolve_path(agent, messages=None) → live plan.md path (or None)
  find_path_in_messages(messages) → most recent plan.md path mentioned
  current_step(messages)          → latest `当前步骤：…` snippet (or "")
  summary(items)                  → (n_done, n_total)
  is_complete(items)              → all done (or empty)

Supported task-line shapes (all matched by `extract`):
  - [ ] foo              ← bullet + open
  - [x] foo              ← bullet + done
  1. [✓] foo             ← numbered + done
  2. [✓ 2026-05-16] foo  ← numbered + timestamped done, content after bracket
  3. [✓ 已生成: foo]      ← numbered + done with description *inside* bracket
  4. [D][P] foo          ← two marker groups (delegate + parallel), still open
  5. [D] foo             ← non-standard marker "D" → open (not done)
"""
from __future__ import annotations
import os, re
from typing import Any, Optional

_DONE_CHARS = set("xX✓✔√☑")
# Newline-insert before a bullet stuck to JSON debris (`{"content": "- [ ] …`).
_GLUE_RE = re.compile(r"(?<!\n)((?:[-*+]|\d+\s*[.)、:）]) \[)")
_BULLET_RE = re.compile(r"^\s*(?:[-*+]|\d+\s*[.)、:）])\s+")
_BRACKET_RE = re.compile(r"\[([^\]]*)\]")
# Strip `✓ ` / `x ` / timestamp prefix when bracket content is used as title.
_INLINE_STRIP_RE = re.compile(
    r"^[" + re.escape("".join(_DONE_CHARS)) + r"]\s*(?:\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}(?::\d{2})?\s*)?"
)
_DEBRIS_RE = re.compile(r'["\\<].*$')
# Strip markdown emphasis since planbar renders rich.Text, not Markdown.
_MD_EMPHASIS_RE = re.compile(
    r"\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|__([^_\n]+)__|_([^_\n]+)_|`([^`\n]+)`"
)
def _strip_md(s: str) -> str:
    return _MD_EMPHASIS_RE.sub(lambda m: next(g for g in m.groups() if g is not None), s)


def _has_done_glyph(marker: str) -> bool:
    return any(c in _DONE_CHARS for c in marker)


def extract(text: str) -> list[tuple[str, str]]:
    if not text: return []
    norm = text.replace("\\n", "\n") if "\\n" in text else text
    norm = _GLUE_RE.sub(r"\n\1", norm)
    found: dict[str, str] = {}
    for line in norm.splitlines():
        head = _BULLET_RE.match(line)
        if not head: continue
        rest = line[head.end():]
        groups: list[str] = []
        # Consume any number of consecutive `[...]` groups — covers `[D][P]`
        # task-type chains as well as the plain `[ ]` / `[x]` single form.
        while True:
            b = _BRACKET_RE.match(rest)
            if not b: break
            groups.append(b.group(1))
            rest = rest[b.end():]
        if not groups: continue
        is_done = any(_has_done_glyph(g) for g in groups)
        inline = rest.strip()
        if inline:
            content = inline
        elif is_done:
            # `[✓ description]` shape — description lives inside the bracket
            # next to the glyph. Strip the glyph + optional timestamp.
            done_g = next(g for g in groups if _has_done_glyph(g))
            content = _INLINE_STRIP_RE.sub("", done_g).strip()
        else:
            continue
        k = _strip_md(_DEBRIS_RE.sub("", content).strip())
        if not k: continue
        status = "done" if is_done else "open"
        # Same content seen twice — done wins over open.
        if k not in found or status == "done":
            found[k] = status
    return list(found.items())


def _stashed_plan_path(agent) -> str:
    # First non-empty `working['in_plan_mode']` from (handler, agent).
    for src in (getattr(agent, "handler", None), agent):
        p = ((getattr(src, "working", None) or {}).get("in_plan_mode") or "").strip()
        if p: return p
    return ""


def _resolve_stashed(p: str) -> Optional[str]:
    if not p: return None
    rel = p.lstrip("./\\")
    cwd = os.getcwd()
    for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
        if os.path.isfile(c) and os.path.getsize(c) > 0: return c
    return None


# Strict per-session discovery — scan this session's own messages only.
_PATH_RE = re.compile(r"""((?:\.\/)?(?:temp\/)?plan_[A-Za-z0-9_\-]+\/plan\.md)""")


def _slice(messages, start_idx: int):
    if not messages: return []
    if start_idx <= 0: return list(messages)
    return list(messages)[start_idx:]


def find_path_in_messages(messages, start_idx: int = 0) -> Optional[str]:
    """Latest existing `plan_XXX/plan.md` referenced after `start_idx`.
    Items can be `ChatMessage`-like (`.content`) or plain strings;
    only paths that exist on disk are returned."""
    sliced = _slice(messages, start_idx)
    if not sliced: return None
    for m in reversed(sliced):
        text = getattr(m, "content", None)
        if text is None: text = m if isinstance(m, str) else ""
        if not text or "plan.md" not in text: continue
        for hit in reversed(_PATH_RE.findall(text)):
            p = _resolve_stashed(hit.strip().strip("\"'"))
            if p: return p
    return None


# Prefer concise `<summary>` narrative over the long plan-item echo;
# treat `❌ 当前步骤:` as "step done", not "current step".
_SUMMARY_STEP_RE = re.compile(
    r"<summary>[^<]*?当前步骤[:：]\s*([^<\n]{1,160})</summary>", re.DOTALL)
_STEP_RE = re.compile(r"📌\s*当前步骤[:：]\s*([^\n。！!？?]{1,160})")
_DONE_STEP_RE = re.compile(r"❌\s*当前步骤[:：]")


def current_step(messages, start_idx: int = 0, max_len: int = 60) -> str:
    """Latest `当前步骤：…` snippet; `<summary>` form preferred, `❌`-prefixed
    skipped. Trimmed to `max_len` chars so it fits the 5-row plan card."""
    sliced = _slice(messages, start_idx)
    if not sliced: return ""

    def _clean(s: str) -> str:
        return _strip_md(re.sub(r"\s+", " ", s).strip().rstrip(" ：:—-"))

    def _cap(s: str) -> str:
        s = _clean(s)
        if len(s) <= max_len: return s
        return s[:max_len - 1].rstrip() + "…"

    for m in reversed(sliced):
        text = getattr(m, "content", None)
        if text is None: text = m if isinstance(m, str) else ""
        if not text or "当前步骤" not in text: continue
        hits = _SUMMARY_STEP_RE.findall(text)
        if hits: return _cap(hits[-1])
        for raw in reversed(_STEP_RE.findall(text)):
            if _DONE_STEP_RE.search(raw): continue
            return _cap(raw)
    return ""


def is_active(agent, messages=None, start_idx: int = 0,
              restored_path: str = "") -> bool:
    """Plan mode is on. Primary: `working['in_plan_mode']`. Then
    `restored_path` — a path recovered from the transcript's structured
    `enter_plan_mode` tool_use by /continue (see continue_cmd.find_plan_entry);
    unlike the message scan it cannot be spoofed by a path typed in chat.
    Legacy fallback: a `plan_*/plan.md` referenced in this session's messages
    (no global scan) — only consulted when `messages` is passed."""
    if _stashed_plan_path(agent): return True
    if restored_path and _resolve_stashed(restored_path): return True
    return find_path_in_messages(messages, start_idx) is not None


def resolve_path(agent, messages=None, start_idx: int = 0,
                 restored_path: str = "") -> Optional[str]:
    p = _resolve_stashed(_stashed_plan_path(agent))
    if p: return p
    if restored_path:
        p = _resolve_stashed(restored_path)
        if p: return p
    return find_path_in_messages(messages, start_idx)


def summary(items: list[tuple[str, str]]) -> tuple[int, int]:
    return sum(1 for _, st in items if st == "done"), len(items)


def is_complete(items: list[tuple[str, str]]) -> bool:
    return not items or all(st == "done" for _, st in items)


# --- Desktop bridge only (APIs above unchanged) ---
_ENTER_PLAN_RE = re.compile(r"""enter_plan_mode\s*\(\s*["']([^"']+)["']""", re.I)


def _msg_content(m) -> str:
    if isinstance(m, str): return m
    c = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
    return c if isinstance(c, str) else ""


def plan_path_mention_in_messages(messages, start_idx: int = 0) -> Optional[str]:
    for m in reversed(_slice(messages, start_idx)):
        text = _msg_content(m)
        if not text: continue
        if "enter_plan_mode" in text and (hit := _ENTER_PLAN_RE.search(text)):
            return hit.group(1).strip().strip("\"'")
        if "plan.md" in text and (hits := _PATH_RE.findall(text)):
            return hits[-1].strip().strip("\"'")
    return None


def _resolve_stashed_at(p: str, root: str) -> Optional[str]:
    if not p or not root: return None
    rel = p.lstrip("./\\")
    cwd = root.rstrip("/\\")
    for c in (p, os.path.join(cwd, "temp", rel), os.path.join(cwd, rel)):
        if os.path.isfile(c) and os.path.getsize(c) > 0: return c
    return None


def _find_path_at(messages, start_idx: int, root: str) -> Optional[str]:
    for m in reversed(_slice(messages, start_idx)):
        text = _msg_content(m)
        if not text or "plan.md" not in text: continue
        for hit in reversed(_PATH_RE.findall(text)):
            if p := _resolve_stashed_at(hit.strip().strip("\"'"), root): return p
    return None


def default_session_plan_path(session_id: str) -> str:
    sid = (session_id or "sess").replace("/", "_")
    return f"temp/plan_{sid}/plan.md"


def is_session_scoped_plan_path(path: str, session_id: str) -> bool:
    """Desktop: only bind/adopt paths under this session's plan_{id}/ tree."""
    if not path:
        return False
    sid = (session_id or "sess").replace("/", "_")
    norm = path.replace("\\", "/").lstrip("./")
    return f"plan_{sid}/" in norm or norm.endswith(f"plan_{sid}/plan.md")


def is_plan_preset_prompt(prompt: str) -> bool:
    p = (prompt or "").lower()
    return "plan_sop" in p or "plan 模式" in p or "plan mode" in p


def bind_plan_session(sess: Any, prompt: str = "", path: Optional[str] = None) -> str:
    """Bind plan card to this session only (avoids sharing plan_demo/ across sessions)."""
    rel = (path or "").strip() or default_session_plan_path(getattr(sess, "id", "") or "")
    sess.plan_path = rel
    msgs = list(getattr(sess, "messages", []) or [])
    sess.plan_scan_baseline = len(msgs)
    return rel


def sync_plan_path_from_text(sess: Any, text: str, root: str) -> None:
    """If agent emits enter_plan_mode(...), keep session bound path in sync."""
    if not text:
        return
    m = _ENTER_PLAN_RE.search(text)
    if not m:
        return
    raw = m.group(1).strip().strip("\"'")
    if not raw:
        return
    sess.plan_path = raw.lstrip("./")


def session_plan_active(sess: Any, agent, messages, start_idx: int, root: str) -> bool:
    """Desktop: active when this session has a bound plan_path (not global plan_*/ scan)."""
    bound = (getattr(sess, "plan_path", None) or "").strip()
    if not bound:
        return False
    if _stashed_plan_path(agent):
        return True
    if _resolve_stashed_at(bound, root):
        return True
    if getattr(sess, "status", "") == "running":
        return True
    # Plan preset bound this session — keep placeholder until plan.md appears or path changes
    if is_session_scoped_plan_path(bound, getattr(sess, "id", "")):
        return True
    return False


def session_plan_resolve(bound: str, root: str) -> Optional[str]:
    if not bound:
        return None
    return _resolve_stashed_at(bound.strip(), root)


def _desktop_resolve_plan_file(sess: Any, bound: str, root: str, agent, messages, base: int) -> Optional[str]:
    """Desktop read path: bound → agent stash → per-session message scan."""
    path = session_plan_resolve(bound, root)
    if path:
        return path
    if agent and (stash := _stashed_plan_path(agent)):
        if p := _resolve_stashed_at(stash, root):
            return p
    return _find_path_at(messages, base, root)


def desktop_plan_payload_from_session(sess: Any, ga_root: str = "") -> dict:
    """Per-session plan card — bound plan_path + tuiapp_v2-style item/placeholder/complete."""
    raw = list(getattr(sess, "messages", []) or [])
    base = max(0, int(getattr(sess, "plan_scan_baseline", 0) or 0))
    if base > len(raw):
        base = len(raw)
    root = (getattr(sess, "cwd", None) or ga_root or "").strip()
    agent = getattr(sess, "agent", None)
    partial = getattr(sess, "partial", None)
    if isinstance(partial, dict) and isinstance(partial.get("content"), str):
        sync_plan_path_from_text(sess, partial["content"], root)
    sid = getattr(sess, "id", "") or ""
    if not (getattr(sess, "plan_path", None) or "").strip():
        mentioned = plan_path_mention_in_messages(raw, base)
        if mentioned and is_session_scoped_plan_path(mentioned, sid):
            sess.plan_path = mentioned.lstrip("./")
        else:
            return {"active": False}
    if not session_plan_active(sess, agent, raw, base, root):
        return {"active": False}
    bound = getattr(sess, "plan_path", "") or ""
    path = _desktop_resolve_plan_file(sess, bound, root, agent, raw, base)
    items = []
    if path:
        try:
            items = [{"content": c, "status": st} for c, st in extract(open(path, encoding="utf-8", errors="replace").read())]
        except OSError:
            pass
    step_msgs = list(raw[base:])
    if isinstance(p := getattr(sess, "partial", None), dict) and isinstance(c := p.get("content"), str) and c:
        step_msgs.append({"content": c})
    step = current_step(step_msgs, start_idx=0)
    if not items:
        hp = getattr(sess, "plan_path", "") or _stashed_plan_path(agent) or ""
        hint = "/".join(hp.replace("\\", "/").rstrip("/").split("/")[-2:]) if hp else "plan.md"
        return {"active": True, "placeholder": True, "done": 0, "total": 0, "complete": False, "step": step, "pathHint": hint, "items": []}
    pairs = [(x["content"], x["status"]) for x in items]
    n_done, n_total = summary(pairs)
    return {"active": True, "placeholder": False, "done": n_done, "total": n_total, "complete": is_complete(pairs), "step": step, "items": items}
