"""Propose User Model connection facts when the user engages a Vault note for discussion."""

from __future__ import annotations

import json
import logging
import os
import re
from datetime import datetime, timedelta, timezone
from typing import Any

from utils.connections_journal import append_connections_journal
from utils.redaction import redact_text

_log = logging.getLogger(__name__)

_STATE_FILENAME = ".vault_discuss_proposal_state.json"
_PROJECT_STATE_PREFIX = "project:"
_DEDUPE_DAYS = 7
_MIN_BODY_CHARS = 40
_EXCERPT_CAP = 160
_PROJECT_EXCERPT_CAP = 120
_MIN_PROJECT_SIGNAL_NOTES = 2
_MAX_PROJECT_TITLES = 3
_MAX_PACK_WIKILINK_PAIRS_FACT = 2
_MAX_PACK_WIKILINK_PAIRS_PROMPT = 6
_UUID_IN_TEXT_RE = re.compile(
    r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
    re.IGNORECASE,
)


def _noticing_gates_enabled() -> bool:
    try:
        from core.config import get_consolidation_config

        cfg = get_consolidation_config()
        return bool(cfg.get("personal_noticing_enabled", True)) and bool(
            cfg.get("vault_noticing_enabled", True)
        )
    except Exception:
        return True


_STATE_REL = f"memory/{_STATE_FILENAME}"


def _state_path(workspace: str, *, for_write: bool = False) -> str | None:
    from utils.path_safety import safe_resolve_path

    abs_path, err = safe_resolve_path(
        workspace,
        _STATE_REL,
        allow_write=for_write,
        create_parents=for_write,
    )
    if err:
        return None
    return abs_path


def _load_state(workspace: str) -> dict[str, str]:
    path = _state_path(workspace, for_write=False)
    if not path:
        return {}
    try:
        with open(path, encoding="utf-8") as fh:
            raw = json.load(fh)
        if isinstance(raw, dict):
            return {str(k): str(v) for k, v in raw.items() if k and v}
    except FileNotFoundError:
        return {}
    except Exception:
        _log.debug("vault discuss state load failed", exc_info=True)
    return {}


def _save_state(workspace: str, state: dict[str, str]) -> bool:
    path = _state_path(workspace, for_write=True)
    if not path:
        _log.warning("vault discuss state path unavailable for workspace")
        return False
    try:
        from persistence import secure_atomic_write

        secure_atomic_write(path, json.dumps(state, indent=2))
        os.chmod(path, 0o600)
        return True
    except Exception:
        _log.warning("vault discuss state save failed", exc_info=True)
        return False


def _parse_iso(raw: str) -> datetime | None:
    text = (raw or "").strip()
    if not text:
        return None
    try:
        if text.endswith("Z"):
            text = text[:-1] + "+00:00"
        dt = datetime.fromisoformat(text)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except Exception:
        return None


def _recently_proposed_for_note(workspace: str, note_id: str) -> bool:
    note_id = (note_id or "").strip()
    if not note_id:
        return False
    state = _load_state(workspace)
    ts = _parse_iso(state.get(note_id, ""))
    if not ts:
        return False
    return datetime.now(timezone.utc) - ts < timedelta(days=_DEDUPE_DAYS)


def _mark_proposed_for_note(workspace: str, note_id: str) -> bool:
    note_id = (note_id or "").strip()
    if not note_id:
        return False
    state = _load_state(workspace)
    state[note_id] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
    return _save_state(workspace, state)


def _project_state_key(project_id: str) -> str:
    return f"{_PROJECT_STATE_PREFIX}{(project_id or '').strip()}"


def _recently_proposed_for_project(workspace: str, project_id: str) -> bool:
    project_id = (project_id or "").strip()
    if not project_id:
        return False
    state = _load_state(workspace)
    ts = _parse_iso(state.get(_project_state_key(project_id), ""))
    if not ts:
        return False
    return datetime.now(timezone.utc) - ts < timedelta(days=_DEDUPE_DAYS)


def _mark_proposed_for_project(workspace: str, project_id: str) -> bool:
    project_id = (project_id or "").strip()
    if not project_id:
        return False
    state = _load_state(workspace)
    state[_project_state_key(project_id)] = datetime.now(timezone.utc).isoformat().replace(
        "+00:00", "Z"
    )
    return _save_state(workspace, state)


def _is_untitled_title(title: str | None) -> bool:
    t = (title or "").strip().lower()
    return not t or t in {"untitled", "(untitled)"}


def _note_has_signal(note: dict[str, Any]) -> bool:
    if not _is_untitled_title(str(note.get("title") or "")):
        return True
    return bool(_meaningful_excerpt(str(note.get("body") or "")))


def _discussed_at_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _append_vault_discuss_journal(
    workspace: str,
    fact: str,
    *,
    source: str,
    label: str,
) -> None:
    try:
        count = append_connections_journal(
            workspace,
            [fact],
            source=source,
            skip_salience=True,
        )
    except Exception:
        _log.warning("%s journal append failed (non-fatal)", label, exc_info=True)
        return
    if count <= 0:
        _log.warning("%s journal append wrote 0 lines (path unavailable or line rejected)", label)


def batch_mark_vault_notes_discussed(
    note_ids: list[str],
    *,
    discussed_at: str | None = None,
) -> int:
    """Persist ``discussed_at`` on each note in *note_ids*. Returns count updated."""
    import chat_storage as cs

    ts = (discussed_at or "").strip() or _discussed_at_iso()
    marked = 0
    for raw_id in note_ids or []:
        note_id = str(raw_id or "").strip()
        if not note_id:
            continue
        try:
            capture = cs.get_capture(note_id)
        except Exception:
            _log.warning("batch discussed_at get_capture failed for %s", note_id[:8], exc_info=True)
            continue
        if not capture or capture.get("deleted_at"):
            continue
        metadata = dict(capture.get("metadata") or {})
        metadata["discussed_at"] = ts
        try:
            updated = cs.update_capture(note_id, metadata=metadata)
        except Exception:
            _log.warning("batch discussed_at update failed for %s", note_id[:8], exc_info=True)
            continue
        if updated:
            marked += 1
        else:
            _log.warning("batch discussed_at update returned no row for %s", note_id[:8])
    return marked


def _neighbor_titles(note_id: str, *, limit: int = 2) -> list[str]:
    import chat_storage as cs

    titles: list[str] = []
    seen: set[str] = set()
    try:
        for row in cs.list_graph_neighbors(note_id, limit=max(limit * 2, 4)):
            title = redact_text(str(row.get("title") or "").strip())
            if not title or _is_untitled_title(title) or title.lower() in seen:
                continue
            seen.add(title.lower())
            titles.append(title)
            if len(titles) >= limit:
                break
    except Exception:
        _log.debug("vault discuss neighbor lookup failed", exc_info=True)
    return titles


def _meaningful_excerpt(body: str) -> str:
    cleaned = redact_text(str(body or "").strip())
    cleaned = _UUID_IN_TEXT_RE.sub("", cleaned)
    cleaned = re.sub(r"\s+", " ", cleaned).strip()
    if len(cleaned) < _MIN_BODY_CHARS:
        return ""
    from utils.noticing_plaintext import sanitize_noticing_plaintext

    return sanitize_noticing_plaintext(cleaned, max_len=_EXCERPT_CAP)


def _compose_connection_fact(capture: dict[str, Any], neighbor_titles: list[str]) -> str:
    title = redact_text(str(capture.get("title") or "Untitled").strip()) or "Untitled"
    title = _UUID_IN_TEXT_RE.sub("", title).strip() or "Untitled"
    excerpt = _meaningful_excerpt(str(capture.get("body") or ""))
    linked = ""
    if neighbor_titles:
        safe_neighbors = [t for t in neighbor_titles if t and not _UUID_IN_TEXT_RE.search(t)]
        if safe_neighbors:
            linked = f" (linked to {', '.join(f'\"{t}\"' for t in safe_neighbors[:2])})"
    if excerpt:
        return (
            f"Vault discuss: you opened \"{title}\" for conversation — {excerpt}{linked}."
        )[:400]
    return f"Vault discuss: you opened \"{title}\" for conversation{linked}."[:400]


def _rank_notes_for_project(notes: list[dict[str, Any]]) -> list[dict[str, Any]]:
    def _sort_key(note: dict[str, Any]) -> tuple[int, int, str]:
        excerpt = _meaningful_excerpt(str(note.get("body") or ""))
        titled = 0 if _is_untitled_title(str(note.get("title") or "")) else 1
        return (1 if excerpt else 0, titled, str(note.get("updated_at") or ""))

    return sorted(notes, key=_sort_key, reverse=True)


def _display_title_for_note(note: dict[str, Any]) -> str:
    title = redact_text(str(note.get("title") or "").strip())
    title = _UUID_IN_TEXT_RE.sub("", title).strip()
    if not title or _is_untitled_title(title):
        return ""
    return title


def _wikilink_label_for_note(note: dict[str, Any]) -> str:
    """Display label for in-pack wikilink hints (title preferred, else short excerpt)."""
    title = _display_title_for_note(note)
    if title:
        return title
    excerpt = _meaningful_excerpt(str(note.get("body") or ""))
    return excerpt[:60] if excerpt else ""


def pack_internal_wikilink_pairs(
    notes: list[dict[str, Any]],
    *,
    max_pairs: int = 2,
) -> list[tuple[str, str]]:
    """Return titled pairs (A, B) with a wikilink edge where both notes are in *notes*."""
    import chat_storage as cs

    pack: list[tuple[str, dict[str, Any]]] = []
    id_to_note: dict[str, dict[str, Any]] = {}
    for note in notes or []:
        note_id = str(note.get("id") or "").strip()
        if note_id:
            pack.append((note_id, note))
            id_to_note[note_id] = note
    pack_ids = set(id_to_note)
    if len(pack_ids) < 2:
        return []

    pairs: list[tuple[str, str]] = []
    seen_edges: set[tuple[str, str]] = set()
    seen_title_pairs: set[tuple[str, str]] = set()
    limit = max(1, min(int(max_pairs or 1), 12))

    for src_id, src_note in pack:
        src_label = _wikilink_label_for_note(src_note)
        if not src_label:
            continue
        try:
            neighbors = cs.list_graph_neighbors(src_id, kinds=["wikilink"], limit=50)
        except Exception:
            _log.warning("pack wikilink neighbor lookup failed for %s", src_id[:8], exc_info=True)
            continue
        for row in neighbors:
            dst_id = str(row.get("neighbor_id") or "").strip()
            if not dst_id or dst_id not in pack_ids or dst_id == src_id:
                continue
            dst_note = id_to_note.get(dst_id) or {}
            dst_label = _wikilink_label_for_note(dst_note)
            if not dst_label:
                continue
            edge_key = tuple(sorted((src_id, dst_id)))
            if edge_key in seen_edges:
                continue
            seen_edges.add(edge_key)
            title_pair = tuple(sorted((src_label, dst_label), key=str.lower))
            if title_pair in seen_title_pairs:
                continue
            seen_title_pairs.add(title_pair)
            pairs.append(title_pair)
            if len(pairs) >= limit:
                return pairs
    return pairs


def format_wikilink_hint_clause(pairs: list[tuple[str, str]]) -> str:
    """Plain-language parenthetical for UM facts / journal (cap-friendly)."""
    if not pairs:
        return ""
    parts = [f'notes "{a}" and "{b}" are linked' for a, b in pairs]
    return f" ({'; '.join(parts)})"


def format_project_engage_wikilink_block(
    notes: list[dict[str, Any]],
    *,
    max_pairs: int = _MAX_PACK_WIKILINK_PAIRS_PROMPT,
) -> str:
    """Markdown block for project engage agent prompt; empty when no in-pack links."""
    pairs = pack_internal_wikilink_pairs(notes, max_pairs=max_pairs)
    if not pairs:
        return ""
    lines = [
        "Internal wikilinks within this pack (both notes included above):",
        *[f'- "{a}" ↔ "{b}"' for a, b in pairs],
        "Start synthesis with linked pairs when they share a theme.",
        "",
    ]
    return "\n".join(lines)


def _compose_project_connection_fact(
    project: dict[str, Any],
    notes: list[dict[str, Any]],
) -> str:
    project_name = redact_text(str(project.get("name") or "Vault project").strip()) or "Vault project"
    project_name = _UUID_IN_TEXT_RE.sub("", project_name).strip() or "Vault project"
    ranked = _rank_notes_for_project(notes)
    titles: list[str] = []
    seen: set[str] = set()
    best_excerpt = ""
    for note in ranked:
        raw_title = redact_text(str(note.get("title") or "").strip())
        raw_title = _UUID_IN_TEXT_RE.sub("", raw_title).strip()
        if raw_title and not _is_untitled_title(raw_title):
            key = raw_title.lower()
            if key not in seen:
                seen.add(key)
                titles.append(raw_title)
        if not best_excerpt:
            best_excerpt = _meaningful_excerpt(str(note.get("body") or ""))
        if len(titles) >= _MAX_PROJECT_TITLES and best_excerpt:
            break

    title_clause = ""
    if titles:
        quoted = ", ".join(f"\"{t}\"" for t in titles[:_MAX_PROJECT_TITLES])
        title_clause = f" — themes include {quoted}"

    link_pairs = pack_internal_wikilink_pairs(notes, max_pairs=_MAX_PACK_WIKILINK_PAIRS_FACT)
    link_clause = format_wikilink_hint_clause(link_pairs)
    excerpt_clause = f" — {best_excerpt[:_PROJECT_EXCERPT_CAP]}" if best_excerpt else ""
    n = len(notes)
    fact = (
        f"Vault project discuss: you opened the \"{project_name}\" notebook ({n} notes)"
        f"{title_clause}{link_clause}{excerpt_clause}."
    )
    if len(fact) <= 400:
        return fact
    if link_clause and excerpt_clause:
        trimmed = (
            f"Vault project discuss: you opened the \"{project_name}\" notebook ({n} notes)"
            f"{title_clause}{link_clause}."
        )
        if len(trimmed) <= 400:
            return trimmed
    return fact[:400]


def maybe_propose_from_vault_project_discuss(
    workspace: str,
    *,
    project: dict[str, Any],
    notes: list[dict[str, Any]],
) -> str:
    """Maybe create one synthesized connections proposal from a Vault project engage."""
    if not (workspace or "").strip():
        return "skipped:no_workspace"
    if not _noticing_gates_enabled():
        return "skipped:vault_noticing_disabled"
    project_id = str(project.get("id") or "").strip()
    if not project_id:
        return "skipped:no_project_id"
    pack = [n for n in (notes or []) if str(n.get("id") or "").strip()]
    if not pack:
        return "skipped:empty_pack"
    if _recently_proposed_for_project(workspace, project_id):
        return "skipped:recent_project_dedupe"
    signal_count = sum(1 for n in pack if _note_has_signal(n))
    if signal_count < _MIN_PROJECT_SIGNAL_NOTES:
        return "skipped:thin_project"

    fact = _compose_project_connection_fact(project, pack)
    project_name = str(project.get("name") or "Vault project").strip() or "Vault project"
    from utils.user_model import BackgroundProposalBudget, propose_background_fact

    budget = BackgroundProposalBudget(max_proposals=1)
    outcome = propose_background_fact(
        workspace,
        fact,
        source="vault_discuss_project",
        rationale_prefix=f"Vault project discussion: {project_name}",
        budget=budget,
        section_hint="connections",
    )
    if outcome == "proposal":
        if not _mark_proposed_for_project(workspace, project_id):
            _log.warning(
                "vault project proposal created but dedupe state not saved project=%s",
                project_id[:8],
            )
        _append_vault_discuss_journal(
            workspace,
            fact,
            source="vault_discuss_project",
            label="vault project",
        )
        return "proposal"
    return f"skipped:{outcome}"


def maybe_propose_from_vault_discuss(workspace: str, capture: dict[str, Any]) -> str:
    """Maybe create a connections proposal from a Vault engage action. Returns outcome label."""
    if not (workspace or "").strip():
        return "skipped:no_workspace"
    if not _noticing_gates_enabled():
        return "skipped:vault_noticing_disabled"
    note_id = str(capture.get("id") or "").strip()
    if not note_id:
        return "skipped:no_note_id"
    if _recently_proposed_for_note(workspace, note_id):
        return "skipped:recent_note_dedupe"
    excerpt = _meaningful_excerpt(str(capture.get("body") or ""))
    title = str(capture.get("title") or "").strip()
    if not excerpt and (not title or title.lower() in {"untitled", "(untitled)"}):
        return "skipped:thin_note"

    neighbors = _neighbor_titles(note_id, limit=2)
    fact = _compose_connection_fact(capture, neighbors)
    from utils.user_model import BackgroundProposalBudget, propose_background_fact

    budget = BackgroundProposalBudget(max_proposals=1)
    rationale_prefix = f"Vault note discussion: {title or 'Untitled'}"
    outcome = propose_background_fact(
        workspace,
        fact,
        source="vault_discuss",
        rationale_prefix=rationale_prefix,
        budget=budget,
        section_hint="connections",
    )
    if outcome == "proposal":
        if not _mark_proposed_for_note(workspace, note_id):
            _log.warning(
                "vault discuss proposal created but per-note dedupe state not saved note=%s",
                note_id[:8],
            )
        _append_vault_discuss_journal(
            workspace,
            fact,
            source="vault_discuss",
            label="vault discuss",
        )
        return "proposal"
    return f"skipped:{outcome}"
