#!/usr/bin/env python3
"""echo_ops.py — high-level ECHO operations layered on the client + index + links.

  resolve  — map a mention to its canonical note path (or where to create it)
  recall   — search, then expand one hop along links/source_notes for connected context
  link     — create bidirectional `## Related` links between two notes
  capture  — one-call write: route + canonical frontmatter + index + auto-link + agent-log

Network goes through echo.py; routing/aliases through echo_index; links through echo_links.
"""

from __future__ import annotations

import json
import os
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo          # noqa: E402
import echo_index as idx_mod   # noqa: E402
import echo_links as links     # noqa: E402

# A fuzzy candidate scoring at/above this blocks `capture` from creating what is very
# likely a duplicate (require --force to create anyway, or --merge-into to update the
# existing entity). Below the gate, candidates are surfaced as a warning only.
DUP_GATE = float(os.environ.get("ECHO_DUP_GATE", "0.6"))

# Existing-entity capture appends a dated bullet under this per-kind heading.
LOG_HEADING = {
    "project": "Session History", "person": "Log", "company": "Log",
    "semantic": "Observations", "area": "Log", "concept": "Notes",
    "reference": "Notes", "meeting": "Notes", "decision": "Notes",
    "skill": "Notes", "episodic": "Notes", "working": "Notes",
}


# ---------------------------------------------------------------- resolve -----
def resolve_op(mention: str) -> dict:
    """Core resolve: mention -> match/candidates dict. No printing (Phase 0 — the same
    return value backs the CLI wrapper and the MCP `echo_resolve` tool)."""
    index = idx_mod.load()
    slug, e = idx_mod.resolve(index, mention)
    if e:
        return {"match": True, "slug": slug, **e}
    # No exact match — surface fuzzy candidates so a shortened/expanded name (e.g. "echo
    # memory" for the project "echo") reveals the existing note instead of looking absent.
    cands = idx_mod.fuzzy_candidates(index, mention)
    out = {"match": False, "mention": mention, "suggest_slug": idx_mod.slugify(mention)}
    if cands:
        out["candidates"] = [{"slug": s, "path": c.get("path"), "title": c.get("title"),
                              "kind": c.get("kind"), "score": sc} for s, c, sc in cands]
        out["note"] = ("no exact match, but similar entities exist (see candidates) — check "
                       "these before creating a new note; reuse the right path or `echo.py link`.")
    else:
        out["note"] = "no index entry — derive a path with the right --kind and create it"
    return out


def resolve(mention: str) -> int:
    print(json.dumps(resolve_op(mention), ensure_ascii=False, indent=2))
    return 0


# ------------------------------------------------------------------- link -----
def link_op(a_path: str, b_path: str) -> dict:
    """Core link: reciprocal `## Related` links, returns the change envelope."""
    import echo_output
    a_changed, b_changed = links.link_bidirectional(a_path, b_path)
    return echo_output.envelope("link", {"a": a_path, "b": b_path,
                                         "a_changed": a_changed, "b_changed": b_changed})


def link(a_path: str, b_path: str, as_json: bool = False) -> int:
    env = link_op(a_path, b_path)
    if as_json:
        print(json.dumps(env, ensure_ascii=False))
        return 0
    print(f"ok: linked {env['a']} <-> {env['b']} "
          f"(added: {'A' if env['a_changed'] else '-'}{'B' if env['b_changed'] else '-'})")
    return 0


# ----------------------------------------------------------------- recall -----
def recall(query, limit: int = 8, as_json: bool = False) -> int:
    """Hybrid lexical (BM25) + graph recall — implemented in echo_recall. Kept here as
    the stable public entrypoint (echo.py and /echo-recall call echo_ops.recall)."""
    import echo_recall  # lazy: only pulls the BM25 modules when recall is actually run
    return echo_recall.recall(query, limit=limit, as_json=as_json)


# ----------------------------------------------------------- agent log -------
def ensure_daily_log(line: str) -> None:
    """Resilient: ensure today's daily note + its `## Agent Log` heading exist, then
    idempotently append the line. Best-effort — never raises into the caller. Writes go
    through the offline queue (2.1.0), so a mid-session outage queues the line instead
    of silently dropping it. Offline edge accepted: if the daily note still doesn't
    exist at replay time, the queued PATCH 400-drops with a loud warning — the agent-log
    line is a convenience trace, not the memory itself."""
    try:
        import echo_queue
        date = echo.today()
        path = f"journal/daily/{date}.md"
        status, body = echo.request("GET", echo.vault_url(path))
        if status == 404:
            ts, tb = echo.request("GET", echo.vault_url("journal/templates/daily-note-template.md"))
            tmpl = tb.decode(errors="replace") if ts == 200 else f"# {date}\n\n## Agent Log\n\n## Related\n"
            tmpl = tmpl.replace("{{date:YYYY-MM-DD}}", date).replace("{{DATE}}", date)
            echo_queue.safe_request("PUT", echo.vault_url(path), data=tmpl.encode(),
                                    headers={"Content-Type": "text/markdown"})
            text = tmpl
        else:
            text = body.decode(errors="replace") if status == 200 else ""
        if status == 200 and not re.search(r"(?m)^## Agent Log\s*$", text):
            echo_queue.safe_request("POST", echo.vault_url(path), data=b"\n\n## Agent Log\n",
                                    headers={"Content-Type": "text/markdown"})
        st2, body2 = echo.request("GET", echo.vault_url(path))
        if st2 == 200 and line in body2.decode(errors="replace"):
            return
        echo_queue.safe_request(
            "PATCH", echo.vault_url(path),
            data=echo.normalize_patch_body((line + "\n").encode(), "append", "heading"),
            headers={"Operation": "append", "Target-Type": "heading",
                     "Target": f"{date}::Agent Log", "Content-Type": "text/markdown"})
    except Exception as exc:
        print(f"echo_ops: agent-log skipped ({exc})", file=sys.stderr)


# ----------------------------------------------------------------- capture ---
def _build_note(fm_type: str, status_v: str, today_s: str, title: str,
                body_text: str, sources: list[str], aliases: list[str] | None = None,
                tags: list[str] | None = None) -> str:
    src = "[" + ", ".join(json.dumps(s) for s in sources) + "]"
    fm = ["---", f"type: {fm_type}"]
    if status_v:
        fm.append(f"status: {status_v}")
    # Never scaffold an empty `tags: []` — an entity note is born complete (the caller
    # seeds the kind as a baseline tag), so the incomplete-frontmatter lint stays quiet.
    tags_v = "[" + ", ".join(tags or []) + "]"
    fm += [f"created: {today_s}", f"updated: {today_s}", f"tags: {tags_v}"]
    if aliases:
        # Obsidian-native, durable home for aliases; sweep folds these back into the index.
        fm.append("aliases: [" + ", ".join(json.dumps(a) for a in aliases) + "]")
    fm += ["agent_written: true", f"source_notes: {src}", "---", "", f"# {title}", ""]
    out = "\n".join(fm)
    if body_text.strip():
        out += body_text.strip() + "\n"
    out += "\n## Related\n"
    return out


def _dated_block(today_s: str, body_text: str) -> tuple[str, str]:
    """Render a capture body as a dated log entry that PRESERVES the whole body:
    the first line rides on the bullet, every further line is indented under it as
    a markdown continuation. Returns (bullet, full_block) — the bullet alone is the
    idempotency key (same first-line-per-day semantics as before)."""
    lines = [ln.rstrip() for ln in body_text.strip().splitlines()]
    bullet = f"- {today_s}: {lines[0] if lines else '(update)'}"
    block = bullet
    for ln in lines[1:]:
        block += "\n" + (f"  {ln}" if ln else "")
    return bullet, block


def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> None:
    # Writes route through the offline queue (2.1.0) so a mid-capture outage queues the
    # update instead of silently losing it. (A fully-offline capture never reaches here —
    # the short-circuit in capture() queues the whole op as one semantic record.)
    import echo_queue
    text = links.get_text(path) or ""
    h1 = links.first_h1(text) or path.rsplit("/", 1)[-1][:-3]
    heading = LOG_HEADING.get(kind, "Notes")
    if not re.search(rf"(?m)^##\s+{re.escape(heading)}\s*$", text):
        echo_queue.safe_request("POST", echo.vault_url(path), data=f"\n\n## {heading}\n".encode(),
                                headers={"Content-Type": "text/markdown"})
    bullet, block = _dated_block(today_s, body_text)
    status, body = echo.request("GET", echo.vault_url(path))
    if status == 200 and bullet in body.decode(errors="replace"):
        return
    echo_queue.safe_request(
        "PATCH", echo.vault_url(path),
        data=echo.normalize_patch_body((block + "\n").encode(), "append", "heading"),
        headers={"Operation": "append", "Target-Type": "heading",
                 "Target": f"{h1}::{heading}", "Content-Type": "text/markdown"})
    echo.cmd_fm(path, "updated", json.dumps(today_s))


def capture_op(kind: str | None, title: str, file_arg: str | None = None, status_v: str = "",
               aliases=None, sources=None, tags=None, date: str | None = None,
               domain: str = "business", inbox: bool = False, no_log: bool = False,
               dry_run: bool = False, force: bool = False,
               merge_into: str | None = None, body_text: str | None = None) -> dict:
    """Core capture (Phase 0): route + frontmatter + index + auto-link + agent-log,
    returning an envelope dict — never printing to stdout (helper chatter from the
    low-level verbs is redirected to stderr). Special outcomes are DATA, not exit
    codes: `duplicate-gate` (ok=false, candidates), `queued:capture` (queued=true),
    `dry-run:*`. Raises echo.EchoError on hard failure. `body_text` may be passed
    directly (MCP path); otherwise it is read from file_arg/stdin."""
    import contextlib
    import echo_output
    import echo_quality

    def chatter():
        # Low-level verbs (cmd_put/cmd_append/cmd_fm) print progress lines; keep
        # stdout clean for the envelope by routing them to stderr wholesale.
        return contextlib.redirect_stdout(sys.stderr)

    if body_text is None:
        body_text = echo.read_body(file_arg).decode("utf-8", errors="replace")
    today_s = echo.today()
    aliases = [a.strip() for a in (aliases or []) if a.strip()]
    sources = [s.strip() for s in (sources or []) if s.strip()]
    tags = [t.strip() for t in (tags or []) if t.strip()]

    def env_for(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False,
                near=None, **extra) -> dict:
        act = f"dry-run:{action}" if dry else action
        data = {"kind": kind, "path": path, "title": title, "links_added": links}
        if near:
            data["near_duplicates"] = near
        data.update(extra)
        return echo_output.envelope(act, data, ok=ok)

    # Unknown home -> defer to the inbox (a single idempotent capture line).
    if inbox or not kind:
        if dry_run:
            return env_for("inbox", "inbox/captures/inbox.md", dry=True)
        line = f"- {today_s}: {title}"
        if body_text.strip():
            line += f" — {body_text.strip().splitlines()[0]}"
        with chatter():
            rc = echo.cmd_append("inbox/captures/inbox.md", line)
        return env_for("inbox", "inbox/captures/inbox.md", ok=rc == 0)

    slug = idx_mod.slugify(title)
    # Offline short-circuit (2.1.0). The routed path needs the entity index and several
    # round-trips; when the vault is unreachable, queue the WHOLE capture as one semantic
    # record — flush replays it back through this function against the index as it is at
    # replay time, so routing, the duplicate gate, and aliasing re-run with fresh state.
    # (Byte-level request replay would freeze a create-vs-update decision made blind.)
    try:
        index = idx_mod.load()
    except echo.EchoError as exc:
        if "unreachable" not in str(exc):
            raise
        if dry_run:
            return echo_output.envelope("dry-run:queued:capture",
                                        {"kind": kind, "title": title, "queued": True})
        import echo_queue
        echo_queue.enqueue_capture({
            "kind": kind, "title": title, "body_text": body_text, "status_v": status_v,
            "aliases": aliases, "sources": sources, "tags": tags, "date": date,
            "domain": domain, "inbox": False, "no_log": no_log,
            "force": force, "merge_into": merge_into, "today": today_s,
        })
        return echo_output.envelope("queued:capture",
                                    {"kind": kind, "title": title, "queued": True})
    match_slug, existing = idx_mod.resolve(index, title)
    # --merge-into: the operator has already identified the canonical entity (e.g. after
    # a duplicate-gate stop) — route this capture as an UPDATE to it, whatever the title.
    if merge_into and not existing:
        match_slug, existing = idx_mod.resolve(index, merge_into)
        if not existing:
            raise echo.EchoError(f"--merge-into '{merge_into}' matches no entity in the "
                                 "index (try `resolve` first)", 2)
    existing_reachable = bool(existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200)

    # Pre-write duplicate gate: a strong fuzzy candidate means this title is very likely
    # an existing entity under a different name. STOP before creating the duplicate —
    # after the fact, the warning arrives too late (the parallel note already exists).
    # gate_candidates applies the 1.5.1 precision rules (same-kind only; a lone shared
    # token blocks only when unique to that entity) so vault-common words can't gate.
    gate_hits = []
    if not existing_reachable and not force and not dry_run:
        gate_hits = [{"slug": s, "path": c.get("path"), "title": c.get("title"),
                      "kind": c.get("kind"), "score": sc}
                     for s, c, sc in idx_mod.gate_candidates(index, title, kind=kind,
                                                             threshold=DUP_GATE)]
    if gate_hits:
        return echo_output.envelope("duplicate-gate", {
            "kind": kind, "title": title, "candidates": gate_hits,
            "note": "likely duplicate — re-run with --merge-into <slug> to update the "
                    "existing entity, or --force to create anyway"}, ok=False)

    if dry_run:
        if existing_reachable:
            return env_for("update", existing["path"], dry=True)
        s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
        near = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
        would_gate = (not force
                      and bool(idx_mod.gate_candidates(index, title, kind=kind,
                                                       threshold=DUP_GATE)))
        return env_for("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
                       dry=True, near=near, would_gate=would_gate)

    near_dupes: list[str] = []
    index_title = title          # title to record in the index entry
    index_aliases = list(aliases)
    with chatter():
        if existing_reachable:
            # Reuse the matched entity's CANONICAL slug — never re-slug from the mention, or
            # two index slugs end up pointing at one note. Keep its title; learn the mention
            # as an alias when it's a new distinctive form, so this name resolves next time.
            slug = match_slug or slug
            path = existing["path"]
            kind = existing.get("kind", kind)
            index_title = existing.get("title") or title
            known = {idx_mod._norm(a) for a in existing.get("aliases", [])} | {match_slug}
            if idx_mod.slugify(title) not in known and len(title.strip()) >= echo_quality.MIN_NAME_LEN:
                index_aliases.append(title)
            _append_to_existing(path, kind, today_s, body_text)
            action = "updated"
        else:
            if not status_v:
                # Every entity note is born with a status — a kind-appropriate default
                # (KIND_STATUS) instead of the old project-only special case. Missing
                # status was the root cause of the frontmatter-completeness drift.
                status_v = idx_mod.KIND_STATUS.get(kind, "active")
            # Surface (don't silently create alongside) an existing entity this resembles —
            # the duplication trap when a shortened name didn't resolve exactly. (Strong
            # candidates already stopped at the duplicate gate above; these are the weak
            # ones, surfaced as a warning.)
            near_dupes = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
            # M2: don't let a 40-char-truncated slug silently collide with a different
            # entity already in the index — disambiguate to slug-2, slug-3, ...
            slug = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
            path = idx_mod.derive_path(kind, slug, date=date, domain=domain)
            note_aliases = sorted((set(index_aliases) | set(idx_mod.derive_aliases(title))) - {slug})
            note_tags = sorted({kind, *tags})  # baseline `- <kind>` tag; --tags enriches
            note = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title,
                               body_text, sources, note_aliases, tags=note_tags)
            echo.cmd_put(path, echo.temp_file(note.encode()))
            action = "created"

        # auto-link FIRST (it mutates the note's ## Related section), so the content
        # hash stored below reflects the note's final state. Uses the pre-update index
        # — it only needs to find OTHER entities named in the body. (M2: is_confident_link
        # rejects short/common tokens so a 3-char alias or a word like "API" can't link
        # everywhere.)
        linked = 0
        for s2, e2 in index.get("entities", {}).items():
            if e2.get("path") in (None, path):
                continue
            names = [n for n in [e2.get("title", ""), s2, *e2.get("aliases", [])] if n]
            if any(echo_quality.is_confident_link(n, body_text) for n in names):
                ca, cb = links.link_bidirectional(path, e2["path"])
                linked += int(ca or cb)

        # The note's final content: hashed into the entity index (the incremental
        # sweep's change detector, 2.3) and fed to the local recall index.
        final_text = links.get_text(path) or ""
        note_h = idx_mod.content_hash(final_text) if final_text else None

        # H3: the entity-index write is the race the review flagged — a bare load->save lets
        # two concurrent captures clobber each other's entry. Route it through a lock-guarded,
        # fresh-re-read transaction.
        import echo_concurrency
        index = echo_concurrency.atomic_index_update(
            lambda idx: idx_mod.upsert(idx, slug, path, kind, index_title, index_aliases,
                                       h=note_h))

        # Keep the local BM25 recall index current for this note (best-effort; a
        # zero-network atomic file write since 2.3).
        try:
            import echo_recall
            echo_recall.update_note(path, final_text)
        except Exception as exc:  # never let recall-index upkeep fail a capture
            print(f"echo_ops: recall-index update skipped ({exc})", file=sys.stderr)

        if not no_log:
            ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")

    return env_for(action, path, links=linked, near=near_dupes)


def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "",
            aliases=None, sources=None, tags=None, date: str | None = None,
            domain: str = "business", inbox: bool = False, no_log: bool = False,
            as_json: bool = False, dry_run: bool = False, force: bool = False,
            merge_into: str | None = None) -> int:
    """CLI wrapper around capture_op: prints exactly what pre-Phase-0 capture printed
    (human prose or the --json envelope) and maps envelope outcomes to exit codes
    (76 duplicate-gate, 2 usage-class errors, 0 otherwise)."""
    try:
        env = capture_op(kind, title, file_arg, status_v=status_v, aliases=aliases,
                         sources=sources, tags=tags, date=date, domain=domain,
                         inbox=inbox, no_log=no_log, dry_run=dry_run, force=force,
                         merge_into=merge_into)
    except echo.EchoError as exc:
        print(f"echo_ops: {exc}", file=sys.stderr)
        return getattr(exc, "code", 1)
    action = env.get("action", "")

    if as_json:
        print(json.dumps(env, ensure_ascii=False))
        if action == "duplicate-gate":
            return 76
        return 0 if env.get("ok") else 1

    if action == "duplicate-gate":
        print(f"STOP: '{title}' likely already exists — not creating a duplicate.")
        for c in env.get("candidates", []):
            print(f"  candidate: {c['slug']} -> {c['path']} (score {c['score']})")
        print("  re-run with --merge-into <slug> to update the existing entity, "
              "or --force to create anyway.")
        return 76  # distinct exit: duplicate gate (cf. 75 lock, 78 config)
    if action == "queued:capture":
        print(f"queued (offline): capture {kind} '{title}' — will replay through "
              "capture on the next reachable session")
        return 0
    if action == "dry-run:queued:capture":
        print("offline: vault unreachable — a real run would queue this capture "
              "for replay on the next reachable session.")
        return 0
    if action.startswith("dry-run:"):
        print(f"would {action.split(':', 1)[1]} {kind or '-'} -> {env.get('path')}")
        if env.get("would_gate"):
            print("note: a real run would STOP at the duplicate gate — use --merge-into "
                  "or --force.")
        return 0
    if action == "inbox":
        return 0 if env.get("ok") else 1
    print(f"ok: {action} {kind} -> {env.get('path')}"
          + (f"; auto-linked {env['links_added']}" if env.get("links_added") else ""))
    near = env.get("near_duplicates")
    if near:
        kind_word = "entity" if len(near) == 1 else "entities"
        print(f"WARNING: similar existing {kind_word} ({', '.join(near)}) — if this is "
              f"the same thing, merge or `echo.py link` instead of keeping a duplicate.")
    return 0 if env.get("ok") else 1
