"""Curated wiki memory layer for durable user-relevant dialogue context.

Phase 1 keeps wiki knowledge separate from runtime session memory:

- runtime memory stays in backend.memory
- wiki drafts live in memory/wiki_drafts.jsonl
- published wiki pages live in docs/wiki/
"""

from __future__ import annotations

import json
import re
import uuid
from datetime import datetime
from pathlib import Path

from backend.config import MEMORY_DIR


ROOT_DIR = Path(__file__).resolve().parent.parent
WIKI_ROOT = ROOT_DIR / "docs" / "wiki"
_WIKI_DRAFTS_FILE = MEMORY_DIR / "wiki_drafts.jsonl"
_MAX_WIKI_DRAFTS = 1000

_DEFAULT_PAGE_TITLES = {
    "ongoing-context.md": "Ongoing Context",
    "important-threads.md": "Important Threads",
    "user-preferences.md": "User Preferences",
}

_LOOKUP_TASK_PREFIXES = (
    "email_lookup",
    "calendar_lookup",
    "search_lookup",
    "contact_lookup",
)

_TECHNICAL_CAPTURE_PATTERNS = [
    re.compile(r"\b(error|failed|failure|bug|regression|incident|stuck|broken|timeout|crash|fix|issue|probleem|fout|mislukt|vast)\b", re.IGNORECASE),
    re.compile(r"\b(qr|launcher|server|neonize|protobuf|repo|repository|codebase|architecture|architectuur|design|ontwerp|phase|fase|implementation|integratie|wiki layer|api key|ollama|uvicorn|frontend|backend|settings?)\b", re.IGNORECASE),
]

_ONGOING_CONTEXT_PATTERNS = [
    re.compile(r"\b(onthoud|remember|bewaar|keep track|later|vervolg|follow-?up|opvolg|lopende|openstaand|pending|belangrijk|important|prioriteit|priority|actiepunt|next step|volgende stap)\b", re.IGNORECASE),
    re.compile(r"\b(dossier|thread|onderwerp|subject|document|rapport|afspraak|meeting|zaak|kwestie|project)\b", re.IGNORECASE),
]

_BROAD_LOOKUP_PATTERNS = [
    re.compile(r"\b(ongelezen|unread|vandaag|today|morgen|tomorrow|deze week|this week|overzicht|summary|samenvat|samenvatting|agenda vandaag|calendar today)\b", re.IGNORECASE),
    re.compile(r"\b(hoeveel|count|toon alles|show all|laat alles zien|alle mails|all emails|alle afspraken|all events)\b", re.IGNORECASE),
]

_WIKI_RETRIEVAL_TRIGGER = re.compile(
    r"\b(eerder|vorige(?:\s+keer)?|remember|onthoud|later|vervolg|follow-?up|opvolg|lopende|openstaand|"
    r"belangrijk|important|prioriteit|priority|dossier|thread|document|rapport|onderwerp|subject|"
    r"context|mijn voorkeur|mijn interesses|wat was ook alweer)\b",
    re.IGNORECASE,
)

_WIKI_RETRIEVAL_NEGATIVE = re.compile(
    r"\b(stuur|send|mail naar|reply|antwoord|agenda voor|maak afspraak|create event|plan afspraak|"
    r"zoek in mijn email|zoek in mijn agenda|what's on my calendar|count emails)\b",
    re.IGNORECASE,
)


def _ensure_parent(path: Path) -> None:
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
    except Exception:
        pass


def _clean_text(value: object, limit: int = 500) -> str:
    return " ".join(str(value or "").strip().split())[:limit]


def _clean_list(values: object, *, item_limit: int = 12, text_limit: int = 160) -> list[str]:
    out: list[str] = []
    seen: set[str] = set()
    for item in values or []:
        text = _clean_text(item, limit=text_limit)
        if not text:
            continue
        key = text.casefold()
        if key in seen:
            continue
        seen.add(key)
        out.append(text)
        if len(out) >= item_limit:
            break
    return out


def _now_iso() -> str:
    return datetime.now().isoformat()


def _today_iso() -> str:
    return datetime.now().date().isoformat()


def _slugify(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").strip().lower())
    slug = re.sub(r"-+", "-", slug).strip("-")
    return slug or "entry"


def _wiki_page_path(target_page: str) -> Path:
    rel = str(target_page or "").replace("\\", "/").strip("/")
    if not rel.endswith(".md"):
        raise ValueError("Wiki target page must end with .md")
    path = (WIKI_ROOT / rel).resolve()
    root = WIKI_ROOT.resolve()
    if root not in path.parents and path != root:
        raise ValueError("Wiki target page must stay under docs/wiki")
    return path


def _load_wiki_drafts() -> list[dict]:
    rows: list[dict] = []
    try:
        if not _WIKI_DRAFTS_FILE.exists():
            return rows
        with _WIKI_DRAFTS_FILE.open("r", encoding="utf-8") as handle:
            for line in handle:
                raw = str(line or "").strip()
                if not raw:
                    continue
                parsed = json.loads(raw)
                if isinstance(parsed, dict):
                    rows.append(parsed)
    except Exception:
        return []
    return rows


def _save_wiki_drafts(rows: list[dict]) -> None:
    try:
        _ensure_parent(_WIKI_DRAFTS_FILE)
        clean_rows = [row for row in (rows or []) if isinstance(row, dict)][-_MAX_WIKI_DRAFTS:]
        with _WIKI_DRAFTS_FILE.open("w", encoding="utf-8") as handle:
            for row in clean_rows:
                handle.write(json.dumps(row, ensure_ascii=False) + "\n")
    except Exception:
        pass


def _sort_drafts(rows: list[dict]) -> list[dict]:
    return sorted(
        rows,
        key=lambda row: (
            str(row.get("updated_at") or row.get("created_at") or ""),
            str(row.get("id") or ""),
        ),
        reverse=True,
    )


def _short_summary(text: str, limit: int = 220) -> str:
    value = _clean_text(text, limit=limit + 40)
    return value if len(value) <= limit else value[:limit].rstrip() + "..."


def _query_terms(text: str) -> set[str]:
    stopwords = {
        "the", "and", "for", "with", "that", "this", "from", "into", "your", "about",
        "van", "het", "een", "met", "dat", "dit", "maar", "naar", "voor", "ook",
        "agent", "project", "repo", "wiki", "code", "app", "system", "deze", "current",
    }
    words = re.findall(r"[a-z0-9_]{3,}", str(text or "").lower())
    return {word for word in words if word not in stopwords}


def _session_scope(session: dict) -> dict:
    summary = dict(session.get("continuity_summary") or {})
    task = dict(session.get("task_state") or {})
    return {
        "task_type": _clean_text(summary.get("task_type") or task.get("task_type") or "generic", limit=80) or "generic",
        "provider_scope": _clean_text(summary.get("provider_scope") or task.get("provider_scope") or "default", limit=80) or "default",
        "time_filter": _clean_text(summary.get("time_filter") or task.get("time_filter") or "none", limit=80) or "none",
        "time_label": _clean_text(summary.get("time_label") or task.get("time_label") or "", limit=80),
        "source_tools": _clean_list(summary.get("source_tools") or []),
        "source_provenance": _clean_list(summary.get("source_provenance") or []),
        "exact_identifiers": _clean_list(summary.get("exact_identifiers") or []),
    }


def _combined_session_text(session: dict, scope: dict) -> str:
    goal = _clean_text(session.get("goal") or "", limit=400)
    final_report = _clean_text(session.get("final_report") or "", limit=700)
    summary = _clean_text((session.get("continuity_summary") or {}).get("text") or "", limit=700)
    parts = [
        goal,
        final_report,
        summary,
        " ".join(scope.get("source_tools") or []),
        " ".join(scope.get("source_provenance") or []),
        " ".join(scope.get("exact_identifiers") or []),
    ]
    return " ".join(part for part in parts if part)


def _is_technical_capture_candidate(task_type: str, combined: str) -> bool:
    safe_task_type = str(task_type or "").strip().lower()
    if any(safe_task_type.startswith(prefix) for prefix in _LOOKUP_TASK_PREFIXES):
        return False
    return all(pat.search(combined) for pat in _TECHNICAL_CAPTURE_PATTERNS)


def _is_broad_lookup_goal(goal: str) -> bool:
    safe_goal = _clean_text(goal, limit=300)
    return any(pat.search(safe_goal) for pat in _BROAD_LOOKUP_PATTERNS)


def _has_focused_identifiers(scope: dict) -> bool:
    identifiers = list(scope.get("exact_identifiers") or [])
    return 0 < len(identifiers) <= 3


def _has_ongoing_context_cue(combined: str) -> bool:
    return any(pat.search(combined) for pat in _ONGOING_CONTEXT_PATTERNS)


def classify_wiki_knowledge_kind(session: dict) -> str:
    if not isinstance(session, dict):
        return "ignore"
    scope = _session_scope(session)
    goal = _clean_text(session.get("goal") or "", limit=400)
    combined = _combined_session_text(session, scope)
    task_type = str(scope.get("task_type") or "").strip().lower()
    if _is_technical_capture_candidate(task_type, combined):
        return "ignore"

    focused_identifiers = _has_focused_identifiers(scope)
    ongoing_cue = _has_ongoing_context_cue(combined)

    if any(task_type.startswith(prefix) for prefix in _LOOKUP_TASK_PREFIXES):
        if focused_identifiers and not _is_broad_lookup_goal(goal):
            return "ongoing_topic" if ongoing_cue else "reference_thread"
        return "lookup"

    if ongoing_cue and (focused_identifiers or scope["source_provenance"]):
        return "ongoing_topic"
    return "ignore"


def _is_durable_session(session: dict) -> bool:
    if not isinstance(session, dict):
        return False
    if not bool(session.get("completed")) or bool(session.get("no_learn")):
        return False
    goal = _clean_text(session.get("goal") or "", limit=300)
    if not goal:
        return False
    summary = dict(session.get("continuity_summary") or {})
    audit = dict(summary.get("audit") or {})
    if audit and not bool(audit.get("ok")):
        return False
    scope = _session_scope(session)
    final_report = _clean_text(session.get("final_report") or summary.get("final_report_preview") or "", limit=1000)
    if len(final_report) < 20 and not scope["exact_identifiers"]:
        return False
    tools = {tool.casefold() for tool in scope["source_tools"]}
    if tools and tools.issubset({"recall_memory"}):
        return False
    if scope["task_type"] == "generic" and not (scope["source_tools"] or scope["source_provenance"]):
        return False
    if classify_wiki_knowledge_kind(session) not in {"reference_thread", "ongoing_topic"}:
        return False
    return True


def _target_page_for_session(session: dict) -> tuple[str, str]:
    scope = _session_scope(session)
    knowledge_kind = classify_wiki_knowledge_kind(session)

    if knowledge_kind == "ongoing_topic":
        return "ongoing-context.md", "Ongoing Context"
    return "important-threads.md", "Important Threads"


def _title_for_session(session: dict, target_page: str) -> str:
    goal = _clean_text(session.get("goal") or "", limit=160)
    if target_page == "ongoing-context.md":
        return _short_summary(goal or "Ongoing context note", limit=90)
    if target_page == "important-threads.md":
        return _short_summary(goal or "Important thread note", limit=90)
    return _short_summary(goal or "User context note", limit=90)


def should_retrieve_wiki_context(goal: str, *, task_state: dict | None = None, user_context: str = "") -> bool:
    goal_text = _clean_text(goal, limit=600)
    context_text = _clean_text(user_context, limit=400)
    haystack = " ".join(part for part in (goal_text, context_text) if part)
    if not haystack:
        return False
    if _WIKI_RETRIEVAL_NEGATIVE.search(haystack):
        return False
    task_type = str((task_state or {}).get("task_type") or "").strip().lower()
    if any(task_type.startswith(prefix) for prefix in _LOOKUP_TASK_PREFIXES):
        return False
    return bool(_WIKI_RETRIEVAL_TRIGGER.search(haystack))


def _page_affinity(query: str, rel_path: str, text: str) -> float:
    haystack = f"{rel_path} {text[:1500]}".lower()
    score = 0.0
    for term in _query_terms(query):
        if term in haystack:
            score += 1.0
    rel_lower = rel_path.lower()
    if any(token in query.lower() for token in ("eerder", "vorige", "onthoud", "remember", "vervolg", "follow-up", "followup", "lopende", "openstaand")) and "ongoing-context" in rel_lower:
        score += 2.5
    if any(token in query.lower() for token in ("document", "rapport", "onderwerp", "subject", "mail", "email", "afspraak", "meeting", "dossier", "thread")) and "important-threads" in rel_lower:
        score += 2.0
    if any(token in query.lower() for token in ("email", "outlook", "gmail")) and "threads" in rel_lower:
        score += 1.5
    if any(token in query.lower() for token in ("calendar", "agenda", "afspraak", "meeting")) and "threads" in rel_lower:
        score += 1.5
    return score


def retrieve_wiki_context(query: str, *, limit: int = 3, max_chars: int = 5000) -> dict:
    safe_query = _clean_text(query, limit=600)
    if not safe_query or not should_retrieve_wiki_context(safe_query):
        return {"used": False, "matches": [], "summary": ""}
    if not WIKI_ROOT.exists():
        return {"used": False, "matches": [], "summary": ""}

    scored: list[tuple[float, dict]] = []
    for path in WIKI_ROOT.rglob("*.md"):
        try:
            text = path.read_text(encoding="utf-8")
        except Exception:
            continue
        rel_path = path.relative_to(WIKI_ROOT).as_posix()
        score = _page_affinity(safe_query, rel_path, text)
        if score <= 0:
            continue
        excerpt = text.strip()[: min(max_chars, 2200)]
        scored.append((score, {"path": rel_path, "score": round(score, 2), "excerpt": excerpt}))

    if not scored:
        return {"used": False, "matches": [], "summary": ""}

    matches = [item for _, item in sorted(scored, key=lambda row: row[0], reverse=True)[: max(1, min(limit, 6))]]
    lines: list[str] = []
    total = 0
    for match in matches:
        excerpt = str(match.get("excerpt") or "").strip()
        if not excerpt:
            continue
        header = f"Source: {match['path']}"
        block = f"{header}\n{excerpt}"
        if total + len(block) > max_chars and total > 0:
            break
        lines.append(block)
        total += len(block)

    summary = "\n\n".join(lines).strip()
    return {
        "used": bool(summary),
        "matches": matches,
        "summary": summary,
    }


def _render_body_markdown(draft: dict) -> str:
    created = str(draft.get("created_at") or "")[:10] or _today_iso()
    scope = {
        "task_type": _clean_text(draft.get("task_type") or "generic", limit=80),
        "provider_scope": _clean_text(draft.get("provider_scope") or "default", limit=80),
        "time_filter": _clean_text(draft.get("time_filter") or "none", limit=80),
        "time_label": _clean_text(draft.get("time_label") or "", limit=80),
    }
    lines = [f"## {created} - {str(draft.get('title') or 'Wiki Note').strip()}", ""]
    lines.append(f"Source session: {str(draft.get('source_session_id') or '').strip()}")
    lines.append("")
    lines.append("Goal")
    lines.append("")
    lines.append(str(draft.get("source_run_goal") or "").strip() or "None.")
    lines.append("")
    lines.append("Scope")
    lines.append("")
    lines.append(
        f"task_type={scope['task_type']}; provider_scope={scope['provider_scope']}; "
        f"time_filter={scope['time_filter']}; time_label={scope['time_label'] or '-'}"
    )
    lines.append("")
    lines.append("Summary")
    lines.append("")
    lines.append(str(draft.get("summary") or "").strip() or "None.")
    lines.append("")
    if draft.get("source_provenance"):
        lines.append("Sources")
        lines.append("")
        for item in draft.get("source_provenance") or []:
            lines.append(f"- {item}")
        lines.append("")
    if draft.get("source_tools"):
        lines.append("Tools")
        lines.append("")
        for item in draft.get("source_tools") or []:
            lines.append(f"- {item}")
        lines.append("")
    if draft.get("exact_identifiers"):
        lines.append("Exact identifiers")
        lines.append("")
        for item in draft.get("exact_identifiers") or []:
            lines.append(f"- {item}")
        lines.append("")
    preview = str(draft.get("final_report_preview") or "").strip()
    if preview:
        lines.append("Final report preview")
        lines.append("")
        lines.append(preview)
        lines.append("")
    return "\n".join(lines).strip() + "\n"


def _draft_payload(draft: dict) -> dict:
    return {
        "id": str(draft.get("id") or ""),
        "created_at": str(draft.get("created_at") or ""),
        "updated_at": str(draft.get("updated_at") or ""),
        "status": str(draft.get("status") or "candidate"),
        "source_session_id": str(draft.get("source_session_id") or ""),
        "source_run_goal": str(draft.get("source_run_goal") or ""),
        "task_type": str(draft.get("task_type") or "generic"),
        "provider_scope": str(draft.get("provider_scope") or "default"),
        "time_filter": str(draft.get("time_filter") or "none"),
        "time_label": str(draft.get("time_label") or ""),
        "source_tools": list(draft.get("source_tools") or []),
        "source_provenance": list(draft.get("source_provenance") or []),
        "exact_identifiers": list(draft.get("exact_identifiers") or []),
        "title": str(draft.get("title") or ""),
        "summary": str(draft.get("summary") or ""),
        "target_page": str(draft.get("target_page") or ""),
        "target_section": str(draft.get("target_section") or ""),
        "body_markdown": str(draft.get("body_markdown") or ""),
        "review_notes": str(draft.get("review_notes") or draft.get("review_reason") or ""),
        "review_source": str(draft.get("review_source") or ""),
        "published_at": str(draft.get("published_at") or ""),
        "published_page": str(draft.get("published_page") or ""),
        "final_report_preview": str(draft.get("final_report_preview") or ""),
        "knowledge_kind": str(draft.get("knowledge_kind") or ""),
    }


def maybe_capture_session_wiki_draft(session: dict, *, auto_publish: bool = True) -> dict | None:
    if not _is_durable_session(session):
        return None

    source_session_id = _clean_text(session.get("id") or "", limit=120)
    if not source_session_id:
        return None

    scope = _session_scope(session)
    target_page, target_section = _target_page_for_session(session)
    summary_text = _short_summary(
        session.get("continuity_summary_text")
        or (session.get("continuity_summary") or {}).get("text")
        or session.get("final_report")
        or session.get("goal")
        or "",
        limit=260,
    )

    rows = _load_wiki_drafts()
    existing = None
    for row in rows:
        if str(row.get("source_session_id") or "") == source_session_id:
            existing = row
            break

    now_iso = _now_iso()
    draft = dict(existing or {})
    draft.update(
        {
            "id": str(draft.get("id") or uuid.uuid4()),
            "created_at": str(draft.get("created_at") or now_iso),
            "updated_at": now_iso,
            "status": "candidate" if auto_publish else str(draft.get("status") or "candidate"),
            "source_session_id": source_session_id,
            "source_run_goal": _clean_text(session.get("goal") or "", limit=220),
            "task_type": scope["task_type"],
            "provider_scope": scope["provider_scope"],
            "time_filter": scope["time_filter"],
            "time_label": scope["time_label"],
            "source_tools": scope["source_tools"],
            "source_provenance": scope["source_provenance"],
            "exact_identifiers": scope["exact_identifiers"],
            "final_report_preview": _short_summary(session.get("final_report") or "", limit=280),
            "continuity_summary": dict(session.get("continuity_summary") or {}),
            "target_page": target_page,
            "target_section": target_section,
            "title": _title_for_session(session, target_page),
            "summary": summary_text,
            "knowledge_kind": classify_wiki_knowledge_kind(session),
            "review_reason": "",
            "review_notes": "",
            "review_source": "auto_curator" if auto_publish else str(draft.get("review_source") or ""),
        }
    )
    draft["body_markdown"] = _render_body_markdown(draft)

    if existing is None:
        rows.append(draft)
    else:
        for idx, row in enumerate(rows):
            if str(row.get("id") or "") == str(draft.get("id") or ""):
                rows[idx] = draft
                break

    _save_wiki_drafts(_sort_drafts(rows))
    if not auto_publish:
        return _draft_payload(draft)
    published = publish_wiki_draft(draft_id=str(draft.get("id") or ""), reviewer="auto_curator")
    return published or _draft_payload(draft)


def get_wiki_status() -> dict:
    rows = _load_wiki_drafts()
    counts: dict[str, int] = {"candidate": 0, "approved": 0, "rejected": 0, "published": 0}
    for row in rows:
        status = str(row.get("status") or "candidate")
        counts[status] = counts.get(status, 0) + 1
    page_count = 0
    if WIKI_ROOT.exists():
        try:
            page_count = len(list(WIKI_ROOT.rglob("*.md")))
        except Exception:
            page_count = 0
    return {
        "ok": True,
        "wikiRoot": str(WIKI_ROOT),
        "wikiExists": WIKI_ROOT.exists(),
        "pageCount": page_count,
        "draftCounts": counts,
        "draftTotal": len(rows),
        "entryTotal": counts.get("published", 0),
        "reviewMode": "automatic",
    }


def list_wiki_drafts(*, status: str = "", limit: int = 50) -> list[dict]:
    wanted = _clean_text(status, limit=40).lower()
    safe_limit = max(1, min(int(limit or 50), 200))
    rows = []
    for row in _sort_drafts(_load_wiki_drafts()):
        row_status = str(row.get("status") or "candidate").lower()
        if wanted and wanted not in {"all", row_status}:
            continue
        rows.append(_draft_payload(row))
        if len(rows) >= safe_limit:
            break
    return rows


def get_wiki_draft(draft_id: str) -> dict | None:
    key = _clean_text(draft_id, limit=120)
    if not key:
        return None
    for row in _load_wiki_drafts():
        if str(row.get("id") or "") == key:
            return _draft_payload(row)
    return None


def delete_wiki_draft(draft_id: str) -> dict | None:
    key = _clean_text(draft_id, limit=120)
    if not key:
        return None
    rows = _load_wiki_drafts()
    removed: dict | None = None
    remaining: list[dict] = []
    for row in rows:
        if removed is None and str(row.get("id") or "") == key:
            removed = dict(row)
            continue
        remaining.append(row)
    if removed is None:
        return None

    page_rel = str(removed.get("published_page") or removed.get("target_page") or "").strip()
    if page_rel:
        try:
            page_path = _wiki_page_path(page_rel)
            if page_path.exists():
                current = page_path.read_text(encoding="utf-8")
                start_marker = f"<!-- wiki-draft:{removed['id']}:start -->"
                end_marker = f"<!-- wiki-draft:{removed['id']}:end -->"
                if start_marker in current and end_marker in current:
                    pattern = re.compile(re.escape(start_marker) + r"[\s\S]*?" + re.escape(end_marker) + r"\n?", re.MULTILINE)
                    updated = pattern.sub("", current, count=1)
                    updated = re.sub(r"\n{3,}", "\n\n", updated).rstrip() + "\n"
                    page_path.write_text(updated, encoding="utf-8")
        except Exception:
            pass

    _save_wiki_drafts(_sort_drafts(remaining))
    return _draft_payload(removed)


def review_wiki_draft(*, draft_id: str, approved: bool, review_reason: str = "", reviewer: str = "manual_api") -> dict | None:
    key = _clean_text(draft_id, limit=120)
    rows = _load_wiki_drafts()
    for idx, row in enumerate(rows):
        if str(row.get("id") or "") != key:
            continue
        updated = dict(row)
        updated["updated_at"] = _now_iso()
        updated["status"] = "approved" if approved else "rejected"
        updated["review_reason"] = _clean_text(review_reason, limit=240)
        updated["review_notes"] = updated["review_reason"]
        updated["review_source"] = _clean_text(reviewer, limit=80) or "manual_api"
        rows[idx] = updated
        _save_wiki_drafts(_sort_drafts(rows))
        return _draft_payload(updated)
    return None


def _ensure_target_page(path: Path, target_page: str) -> None:
    if path.exists():
        return
    _ensure_parent(path)
    rel = str(target_page or "").replace("\\", "/")
    title = _DEFAULT_PAGE_TITLES.get(rel, Path(rel).stem.replace("-", " ").title())
    created = _today_iso()
    path.write_text(
        "\n".join(
            [
                "---",
                f"title: {title}",
                "source: session",
                f"created: {created}",
                "tags: [wiki]",
                "status: current",
                "---",
                "",
                f"# {title}",
                "",
            ]
        ),
        encoding="utf-8",
    )


def publish_wiki_draft(
    *,
    draft_id: str,
    reviewer: str = "manual_api",
    title: str = "",
    target_page: str = "",
) -> dict | None:
    key = _clean_text(draft_id, limit=120)
    rows = _load_wiki_drafts()
    for idx, row in enumerate(rows):
        if str(row.get("id") or "") != key:
            continue
        updated = dict(row)
        if str(updated.get("status") or "") == "rejected":
            return None
        if title:
            updated["title"] = _clean_text(title, limit=120)
        if target_page:
            updated["target_page"] = str(target_page).replace("\\", "/").strip("/")
        updated["updated_at"] = _now_iso()
        updated["status"] = "published"
        updated["published_at"] = updated["updated_at"]
        updated["review_source"] = _clean_text(reviewer, limit=80) or str(updated.get("review_source") or "manual_api")
        if not str(updated.get("title") or "").strip():
            updated["title"] = "Wiki Note"
        updated["body_markdown"] = _render_body_markdown(updated)

        page_rel = str(updated.get("target_page") or "decisions.md")
        page_path = _wiki_page_path(page_rel)
        _ensure_target_page(page_path, page_rel)
        start_marker = f"<!-- wiki-draft:{updated['id']}:start -->"
        end_marker = f"<!-- wiki-draft:{updated['id']}:end -->"
        block = f"{start_marker}\n{updated['body_markdown'].rstrip()}\n{end_marker}\n"
        current = page_path.read_text(encoding="utf-8")
        if start_marker in current and end_marker in current:
            pattern = re.compile(re.escape(start_marker) + r"[\s\S]*?" + re.escape(end_marker) + r"\n?", re.MULTILINE)
            current = pattern.sub(block, current, count=1)
        else:
            current = current.rstrip() + "\n\n" + block
        page_path.write_text(current, encoding="utf-8")

        updated["published_page"] = page_rel
        rows[idx] = updated
        _save_wiki_drafts(_sort_drafts(rows))
        return _draft_payload(updated)
    return None