"""Outbox / WriteIntent journal for save_knowledge — v10 P1.6.

Beever Atlas's persister opens a `WriteIntent` document in MongoDB before
touching its three stores (Weaviate / Neo4j / queues), then closes it on
success. A background `WriteReconciler` retries any intents that were
left open by a crash. We do the same in SQLite.

Lifecycle:

  pending       — created at the top of save_knowledge
     │
     ├── on success → committed(knowledge_id, committed_at)
     ├── on hard failure → failed(last_error)
     └── on duplicate dedup → superseded (the dedup path returned a
                              previously-saved record id; the intent is
                              honoured but no new row was inserted)

The reconciler runs in Store.__init__ after migrations apply. It picks up
`pending` rows older than the grace window (default 60s — long enough for
genuinely in-flight saves not to be stomped on), parses the payload, and
calls a replay function the caller injects. The replay must be idempotent
under content_hash dedup; save_knowledge already is.
"""

from __future__ import annotations

import hashlib
import json
import os
import sys
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable, Iterable

LOG = lambda msg: sys.stderr.write(f"[outbox] {msg}\n")

_DEFAULT_GRACE_SECONDS = 60
_DEFAULT_MAX_ATTEMPTS = 3
_DEFAULT_RECONCILE_LIMIT = 50


# ──────────────────────────────────────────────
# Data
# ──────────────────────────────────────────────


@dataclass
class WriteIntent:
    id: int
    intent_uuid: str
    session_id: str | None
    content_hash: str
    payload: dict[str, Any]
    status: str
    knowledge_id: int | None
    attempts: int
    last_error: str | None
    created_at: str
    updated_at: str
    committed_at: str | None


# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────


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


def compute_content_hash(content: str, ktype: str | None, project: str | None) -> str:
    """sha1 of (type || project || content) — used for replay idempotency."""
    blob = f"{(ktype or '')}|{(project or '')}|{content or ''}".encode("utf-8")
    return hashlib.sha1(blob).hexdigest()


def _grace_seconds() -> int:
    raw = os.environ.get("MEMORY_OUTBOX_GRACE_SEC")
    if not raw:
        return _DEFAULT_GRACE_SECONDS
    try:
        return max(0, int(raw))
    except ValueError:
        return _DEFAULT_GRACE_SECONDS


def _max_attempts() -> int:
    raw = os.environ.get("MEMORY_OUTBOX_MAX_ATTEMPTS")
    if not raw:
        return _DEFAULT_MAX_ATTEMPTS
    try:
        return max(1, int(raw))
    except ValueError:
        return _DEFAULT_MAX_ATTEMPTS


def _enabled() -> bool:
    raw = os.environ.get("MEMORY_OUTBOX_ENABLED", "true").strip().lower()
    return raw not in ("0", "false", "off", "no")


# ──────────────────────────────────────────────
# Public API
# ──────────────────────────────────────────────


def is_enabled() -> bool:
    return _enabled()


def create_intent(
    db,
    *,
    payload: dict[str, Any],
    session_id: str | None,
    content: str,
    ktype: str | None,
    project: str | None,
) -> WriteIntent | None:
    """Insert a `pending` write_intents row. Returns None when outbox
    is disabled or the table is missing — caller should still proceed
    with the save (graceful degradation)."""
    if not _enabled():
        return None
    intent_uuid = uuid.uuid4().hex[:16]
    chash = compute_content_hash(content, ktype, project)
    now = _now()
    try:
        cur = db.execute(
            """INSERT INTO write_intents (
                intent_uuid, session_id, content_hash, payload_json,
                status, attempts, created_at, updated_at
            ) VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)""",
            (intent_uuid, session_id, chash, json.dumps(payload), now, now),
        )
        db.commit()
        return WriteIntent(
            id=cur.lastrowid, intent_uuid=intent_uuid,
            session_id=session_id, content_hash=chash,
            payload=payload, status="pending",
            knowledge_id=None, attempts=0, last_error=None,
            created_at=now, updated_at=now, committed_at=None,
        )
    except Exception as exc:
        LOG(f"create_intent failed (continuing without outbox): {exc}")
        return None


def mark_committed(db, intent: WriteIntent | None, knowledge_id: int | None) -> None:
    if intent is None:
        return
    now = _now()
    try:
        db.execute(
            "UPDATE write_intents SET status='committed', knowledge_id=?, "
            "committed_at=?, updated_at=? WHERE id=?",
            (knowledge_id, now, now, intent.id),
        )
        db.commit()
    except Exception as exc:
        LOG(f"mark_committed({intent.id}) failed: {exc}")


def mark_superseded(db, intent: WriteIntent | None, knowledge_id: int | None) -> None:
    """Use when save_knowledge returned an existing dedup target — we want
    the intent recorded as resolved, but with a distinct status so audits
    can tell genuine commits apart from no-op dedups."""
    if intent is None:
        return
    now = _now()
    try:
        db.execute(
            "UPDATE write_intents SET status='superseded', knowledge_id=?, "
            "committed_at=?, updated_at=? WHERE id=?",
            (knowledge_id, now, now, intent.id),
        )
        db.commit()
    except Exception as exc:
        LOG(f"mark_superseded({intent.id}) failed: {exc}")


def mark_failed(db, intent: WriteIntent | None, error: str) -> None:
    if intent is None:
        return
    now = _now()
    try:
        db.execute(
            "UPDATE write_intents SET attempts=attempts+1, last_error=?, "
            "updated_at=? WHERE id=?",
            (error[:1000], now, intent.id),
        )
        # If we've blown the attempt budget, freeze the intent in 'failed'.
        db.execute(
            "UPDATE write_intents SET status='failed' "
            "WHERE id=? AND attempts >= ?",
            (intent.id, _max_attempts()),
        )
        db.commit()
    except Exception as exc:
        LOG(f"mark_failed({intent.id}) failed: {exc}")


def claim_pending(
    db, *, grace_seconds: int | None = None, limit: int | None = None
) -> list[WriteIntent]:
    """Return pending intents older than the grace window."""
    grace = _grace_seconds() if grace_seconds is None else grace_seconds
    cap = limit if limit is not None else _DEFAULT_RECONCILE_LIMIT
    cutoff = (
        datetime.now(timezone.utc).timestamp() - grace
    )
    cutoff_iso = (
        datetime.fromtimestamp(cutoff, tz=timezone.utc)
        .isoformat().replace("+00:00", "Z")
    )
    try:
        rows = db.execute(
            """SELECT id, intent_uuid, session_id, content_hash, payload_json,
                       status, knowledge_id, attempts, last_error,
                       created_at, updated_at, committed_at
                 FROM write_intents
                WHERE status = 'pending' AND created_at < ?
                ORDER BY created_at ASC LIMIT ?""",
            (cutoff_iso, cap),
        ).fetchall()
    except Exception as exc:
        LOG(f"claim_pending query failed: {exc}")
        return []

    results: list[WriteIntent] = []
    for row in rows:
        try:
            payload = json.loads(row[4]) if row[4] else {}
        except Exception:
            payload = {}
        results.append(
            WriteIntent(
                id=row[0], intent_uuid=row[1], session_id=row[2],
                content_hash=row[3], payload=payload, status=row[5],
                knowledge_id=row[6], attempts=row[7], last_error=row[8],
                created_at=row[9], updated_at=row[10], committed_at=row[11],
            )
        )
    return results


def find_existing_by_hash(db, content_hash: str) -> int | None:
    """Look for a knowledge record whose content_hash already matches —
    means the previous save committed to `knowledge` even if the intent
    didn't get marked. Reconciler uses this to avoid double-inserts.

    Implementation hits the dedup path indirectly: we re-derive the hash
    on the fly from `knowledge` rows of the same content. SQLite is happy
    to answer this in one row scan because content_hash is not stored
    separately — we filter by status + project + type + content textual
    equality on the rare reconcile path."""
    return None  # see note in reconcile_pending below


def reconcile_pending(
    db,
    replay_fn: Callable[[dict[str, Any]], int | None],
    *,
    grace_seconds: int | None = None,
    limit: int | None = None,
) -> dict[str, int]:
    """Walk pending intents, replay each via `replay_fn(payload)`.

    `replay_fn` should call save_knowledge(**payload, _from_outbox=True)
    and return the committed knowledge_id (or None on dedup). It must
    raise on hard failures so we can record `attempts`.

    Returns counts of {replayed, dedup, failed, skipped}.
    """
    counts = {"replayed": 0, "dedup": 0, "failed": 0, "skipped": 0}
    pending = claim_pending(db, grace_seconds=grace_seconds, limit=limit)
    if not pending:
        return counts

    for intent in pending:
        if intent.attempts >= _max_attempts():
            mark_failed(db, intent, "max attempts already reached")
            counts["failed"] += 1
            counts["skipped"] += 1
            continue

        try:
            new_id = replay_fn(intent.payload)
        except Exception as exc:
            LOG(
                f"replay failed for intent {intent.intent_uuid} "
                f"(attempt {intent.attempts + 1}): {exc}"
            )
            mark_failed(db, intent, str(exc))
            counts["failed"] += 1
            continue

        if new_id is None:
            # Dedup hit — the underlying save returned a previously
            # existing record. Mark accordingly.
            mark_superseded(db, intent, None)
            counts["dedup"] += 1
            continue

        mark_committed(db, intent, new_id)
        counts["replayed"] += 1

    return counts


# ──────────────────────────────────────────────
# Stats helpers (for dashboards / memory_stats)
# ──────────────────────────────────────────────


def get_status_counts(db) -> dict[str, int]:
    try:
        rows = db.execute(
            "SELECT status, COUNT(*) FROM write_intents GROUP BY status"
        ).fetchall()
    except Exception:
        return {}
    return {row[0]: row[1] for row in rows}
