"""Write and update markdown files in the vault, preserving YAML frontmatter.

Versioning strategy: caller-driven, not heuristic.
- User/API edits: versioned=True (default) — always saves history
- Pipeline operations (compile, cross-link): versioned=False — no history noise
"""

from __future__ import annotations

import hashlib
import logging
import re
import threading
from datetime import UTC, datetime
from pathlib import Path

import frontmatter

from agntspace.vault.paths import VaultPaths

log = logging.getLogger(__name__)

# Directories where version history is available (logical prefixes)
_VERSIONED_DIRS = {"knowledge/"}

# Windows reserved device names (cannot be used as filenames)
_WINDOWS_RESERVED = frozenset({
    "CON", "PRN", "AUX", "NUL",
    *(f"COM{i}" for i in range(1, 10)),
    *(f"LPT{i}" for i in range(1, 10)),
})

_MAX_FILENAME_LEN = 180  # Leave room for directory path within Windows MAX_PATH


def title_to_filename(title: str) -> str:
    """Convert a display title to a safe, human-readable filename (without extension).

    Design: filenames should look like the title, not a slug.
    'Sarah Chen' → 'Sarah Chen'  (not 'sarah-chen')
    'AC/DC'      → 'AC-DC'       (/ is path separator)
    'What: Why?' → 'What Why'    (strip illegal chars)
    'CON'        → 'CON_'        (Windows reserved)

    The result is safe on Windows, macOS, and Linux.
    """
    if not title or not title.strip():
        return "untitled"

    # Strip characters illegal in filenames on any OS: / \ : * ? " < > |
    name = re.sub(r'[/\\:*?"<>|]', "", title)

    # Collapse multiple spaces, strip leading/trailing whitespace and dots
    name = re.sub(r"\s+", " ", name).strip().strip(".")

    # Handle Windows reserved device names
    base = name.split(".")[0].upper()
    if base in _WINDOWS_RESERVED:
        name = name + "_"

    # Truncate to safe length
    if len(name) > _MAX_FILENAME_LEN:
        name = name[:_MAX_FILENAME_LEN].rstrip()

    return name or "untitled"


class VaultWriter:
    """Creates and updates markdown files in the vault directory."""

    # ── Recent API-write tracking (for VaultEditWatcher) ──
    # Shared class-level dict so any writer instance is visible to the watcher.
    # Maps absolute_path_str → (mtime_after_write, expiry_ts). Entries older
    # than _RECENT_WRITE_TTL seconds are considered manual edits.
    _recent_writes: dict[str, tuple[float, float]] = {}
    _RECENT_WRITE_TTL: float = 30.0

    @classmethod
    def was_recently_written(cls, abs_path: str, current_mtime: float) -> bool:
        """Check if this path+mtime was produced by a recent API write.

        Returns True if the VaultWriter wrote this file within the TTL window
        and the on-disk mtime matches what we stamped. Mtime mismatch means
        the user edited the file after our write.
        """
        import time as _time
        entry = cls._recent_writes.get(abs_path)
        if not entry:
            return False
        stamped_mtime, expiry = entry
        if _time.time() > expiry:
            cls._recent_writes.pop(abs_path, None)
            return False
        # Allow small float drift (filesystem mtime resolution)
        return abs(stamped_mtime - current_mtime) < 1.0

    @classmethod
    def _stamp_recent_write(cls, abs_path: str, mtime: float) -> None:
        """Record that this path was just written via the API."""
        import time as _time
        now = _time.time()
        cls._recent_writes[abs_path] = (mtime, now + cls._RECENT_WRITE_TTL)
        # Opportunistic cleanup
        if len(cls._recent_writes) > 200:
            expired = [k for k, (_, exp) in cls._recent_writes.items() if now > exp]
            for k in expired:
                cls._recent_writes.pop(k, None)

    def __init__(self, vault_dir: Path, *, paths: VaultPaths | None = None) -> None:
        self._vault_dir = vault_dir
        self._paths = paths
        self._write_lock = threading.Lock()  # guards file I/O in write()/append()
        self._pipeline_mode = False  # when True, all writes skip versioning
        # When set, every write inside this process-level pipeline scope
        # inherits this trust_reason unless the caller passes its own
        # contract_action or trust_reason. Set by services that own an
        # entire correlation scope (e.g. KB ingest) and need every write
        # inside it to pass the runtime guard without touching dozens of
        # individual `writer.write(...)` call sites. Cleared on exit.
        self._default_trust_reason: str | None = None
        self._activity: object | None = None  # injected post-hoc — ActivityLogger
        self._scope_writes: object | None = None  # injected; ScopeWritesRecorder

    # ─────────────────────────────────────────────────────────────────
    # Runtime contract-bypass guard
    # ─────────────────────────────────────────────────────────────────
    #
    # Every write goes through `_check_authorization()` before any file
    # I/O happens. The guard makes Rule #3 (Contract is the law) true in
    # code, not just convention: a write inside a correlation scope MUST
    # be backed by a real contract decision, and internal/trusted writes
    # MUST declare the reason they're bypassing.
    #
    # Four-case decision table:
    #
    #   | correlation scope? | trust_reason? | contract_action? | result        |
    #   |-------------------|---------------|-------------------|---------------|
    #   | no                | any           | any               | ALLOW (log)   |
    #   | yes               | set           | ignored           | ALLOW (log)   |
    #   | yes               | unset         | unset             | BLOCK         |
    #   | yes               | unset         | set, authorized   | ALLOW (log)   |
    #   | yes               | unset         | set, unauthorized | BLOCK         |
    #
    # "No correlation scope" is the trusted-internal case: init, migrations,
    # daily cycle, memory consolidation, background workers. None of these
    # run inside a correlation_scope(), so the guard falls through.
    #
    # "Inside scope with trust_reason" is the legitimate-but-external case:
    # an API route or tool handler that's inside a correlation scope but
    # writes something the contract doesn't model (e.g., onboarding init
    # writes PROFILE.md before the contract even exists). These callers
    # document WHY they're bypassing via trust_reason, which lands in the
    # activity log for audit.
    #
    # Everything else must declare a contract_action and that action must
    # be in the authorized-actions set for the current scope (populated
    # by ContractEnforcer.check()). This closes the "plugin imports
    # VaultWriter directly" bypass from threat model §9.2.

    def _check_authorization(
        self,
        relative_path: str,
        contract_action: str | None,
        trust_reason: str | None,
    ) -> str:
        """Guard the write. Returns the short audit label for the write.

        Raises:
            ContractBypassError: if the guard rejects the write.
        """
        # Lazy imports — writer is imported very early at startup, before
        # the activity/security modules are fully wired.
        from agntspace.activity.decisions import (
            authorized_actions_snapshot,
            current_correlation_id,
        )
        from agntspace.security.middleware import ContractBypassError

        corr_id = current_correlation_id()

        # Case 1: No correlation scope → trusted internal write.
        # Covers init, migrations, daily cycle, memory consolidation,
        # heartbeat, background workers, tests. These paths run outside
        # any scope because they were never part of a user-initiated
        # correlation chain.
        if not corr_id:
            return "internal"

        # Apply a scope-level default trust_reason if the caller didn't
        # pass one. Services like KB ingest set this at the entry point
        # so every writer.write() inside the batch inherits it — avoids
        # touching dozens of call sites while still leaving an audit
        # trail via the returned label. The explicit caller value wins.
        if not contract_action and not trust_reason:
            default = getattr(self, "_default_trust_reason", None)
            if default:
                trust_reason = default

        # Case 2: Explicit opt-out — inside a scope but the caller
        # declares it's legitimately bypassing because the write is
        # pre-contract (onboarding init, initial vault setup) or is
        # documented as trusted for a specific reason.
        if trust_reason:
            return f"trusted:{trust_reason}"

        # Case 3: Inside a scope with no contract_action — bypass.
        if not contract_action:
            authorized = authorized_actions_snapshot()
            # Emit a high-importance security_violation event before
            # raising so the user sees it in the next agent turn.
            self._emit_security_violation(
                relative_path, None, authorized, corr_id,
            )
            raise ContractBypassError(
                path=relative_path,
                attempted_action=None,
                authorized=authorized,
                correlation_id=corr_id,
            )

        # Case 4: Inside a scope with a declared contract_action — verify
        # it was authorized by the ContractEnforcer in this same scope.
        authorized = authorized_actions_snapshot()
        if contract_action not in authorized:
            self._emit_security_violation(
                relative_path, contract_action, authorized, corr_id,
            )
            raise ContractBypassError(
                path=relative_path,
                attempted_action=contract_action,
                authorized=authorized,
                correlation_id=corr_id,
            )

        return f"authorized:{contract_action}"

    def _emit_security_violation(
        self,
        path: str,
        attempted_action: str | None,
        authorized: frozenset[str],
        corr_id: str,
    ) -> None:
        """Emit a high-importance activity event for a contract bypass attempt."""
        if self._activity and hasattr(self._activity, "log"):
            try:
                self._activity.log(  # type: ignore[attr-defined]
                    "contract",
                    "security_violation",
                    f"vault write bypass: {path}",
                    {
                        "path": path,
                        "attempted_action": attempted_action,
                        "authorized_in_scope": sorted(authorized),
                        "correlation_id": corr_id,
                    },
                    importance="high",
                )
            except Exception:
                log.exception("failed to emit security_violation event")

    def _resolve(self, relative_path: str) -> Path:
        """Resolve a logical vault path to a physical path."""
        if self._paths:
            return self._paths.resolve(relative_path)
        return self._vault_dir / relative_path

    def pipeline(self) -> _PipelineContext:
        """Context manager to suppress versioning during pipeline operations.

        Usage:
            with writer.pipeline():
                writer.write(...)  # no versioning
                writer.append(...)  # no versioning
        """
        return _PipelineContext(self)

    def trusted_scope(self, reason: str) -> _TrustedScopeContext:
        """Context manager that sets a default `trust_reason` for every
        write inside the `with` block.

        Exists so batch operations — KB ingest, daily cycle, memory
        consolidation — can declare ONCE that every write inside them is
        trusted (with a documented reason), instead of threading a
        `trust_reason=...` kwarg through dozens of internal call sites.
        The explicit caller value still wins if a specific call passes
        its own `contract_action` or `trust_reason`.

        Usage:
            with writer.trusted_scope("kb_ingest_pipeline"):
                for file in files:
                    writer.write(...)  # inherits trust_reason
                    other.do_work()    # any nested writer call also inherits

        Not re-entrant safe for different reasons: nesting the same
        writer in two trusted_scope blocks stacks the reasons; the inner
        wins, then the outer is restored on exit. That's intended.
        """
        return _TrustedScopeContext(self, reason)

    def write(
        self,
        relative_path: str,
        content: str,
        metadata: dict | None = None,
        versioned: bool = True,
        *,
        contract_action: str | None = None,
        trust_reason: str | None = None,
    ) -> Path:
        """Write a vault file. Creates parent directories if needed.

        versioned=True (default): saves previous version to .history/ if file
        is in a versioned directory (knowledge/wiki/) and content actually changed.
        versioned=False: skip versioning (for pipeline operations).
        Pipeline mode (via writer.pipeline() context) also suppresses versioning.

        contract_action: the contract action authorizing this write. Required
            when the call is inside a correlation scope (agent tool call,
            API request, workflow step). Must match an action that the
            ContractEnforcer has already `check()`ed in this scope.

        trust_reason: opt-out from the runtime guard for callers that are
            legitimately internal (init, migrations, daily cycle, memory
            consolidation) OR are inside a scope but write something the
            contract doesn't model (onboarding init, initial setup). Pass
            a short stable string — it's logged to activity for audit.

        Raises:
            ContractBypassError: if the call is inside a correlation scope
                and neither contract_action nor trust_reason is set, or if
                contract_action isn't in the scope's authorized set.
        """
        audit_label = self._check_authorization(
            relative_path, contract_action, trust_reason,
        )

        path = self._resolve(relative_path)

        # Scope snapshot BEFORE any mutation so we always capture the
        # pre-write state. Gated on having a correlation_id — cheap no-op
        # when outside a scope.
        scope_pre_hash: str | None = None
        scope_history_version: str | None = None
        existed_before = path.is_file()
        try:
            from agntspace.activity.decisions import current_correlation_id
            cid = current_correlation_id() or ""
        except Exception:
            cid = ""
        if cid and self._scope_writes is not None:
            scope_pre_hash, scope_history_version = self._scope_snapshot(path, cid)

        with self._write_lock:
            path.parent.mkdir(parents=True, exist_ok=True)

            effective_versioned = versioned and not self._pipeline_mode
            if effective_versioned and path.is_file() and self._is_versionable(relative_path):
                self._save_version(path)

            if metadata:
                post = frontmatter.Post(content, **metadata)
                path.write_text(frontmatter.dumps(post), encoding="utf-8")
            else:
                path.write_text(content, encoding="utf-8")

        # Record this as a real mutation in the current skill scope (if any)
        # so Agent.invoke_skill can detect zero-effect runs and re-queue.
        try:
            from agntspace.activity.decisions import note_vault_write
            note_vault_write()
        except Exception:
            pass

        # Scope-write trail for turn-revert. Intentionally AFTER the
        # file write + note_vault_write so we only record successful
        # writes. Uses the pre-snapshot we captured at the top.
        if cid and self._scope_writes is not None:
            self._record_scope_write_if_any(
                relative_path, path, scope_pre_hash,
                scope_history_version, existed_before,
            )

        # Stamp as API-initiated so VaultEditWatcher ignores this change
        try:
            self._stamp_recent_write(str(path.resolve()), path.stat().st_mtime)
        except OSError:
            pass

        # Log vault write for observability. The audit_label exposes the
        # guard's decision (internal/trusted:<reason>/authorized:<action>)
        # so a future operator grepping activity logs can see exactly
        # which authorization path each write took.
        if self._activity and hasattr(self._activity, "log"):
            try:
                self._activity.log(
                    "system", "vault_write", relative_path,
                    {
                        "path": relative_path,
                        "size": len(content),
                        "versioned": effective_versioned,
                        "auth": audit_label,
                    },
                    importance="low",
                )
            except Exception:
                pass

        return path

    def append(
        self,
        relative_path: str,
        text: str,
        versioned: bool = False,
        *,
        contract_action: str | None = None,
        trust_reason: str | None = None,
    ) -> None:
        """Append text to an existing vault file.

        versioned defaults to False for append — appends are typically pipeline
        operations (compile enrichment markers). Set True for user-initiated appends.

        contract_action / trust_reason: see `write()` for semantics. Same
        runtime guard applies to appends as to full writes.
        """
        self._check_authorization(relative_path, contract_action, trust_reason)

        path = self._resolve(relative_path)
        if not path.is_file():
            raise FileNotFoundError(f"Vault file not found: {path}")

        # Scope snapshot BEFORE mutation — same pattern as write().
        # append always targets an existing file, so action is always 'modify'.
        scope_pre_hash: str | None = None
        scope_history_version: str | None = None
        try:
            from agntspace.activity.decisions import current_correlation_id
            cid = current_correlation_id() or ""
        except Exception:
            cid = ""
        if cid and self._scope_writes is not None:
            scope_pre_hash, scope_history_version = self._scope_snapshot(path, cid)

        with self._write_lock:
            effective_versioned = versioned and not self._pipeline_mode
            if effective_versioned and self._is_versionable(relative_path):
                self._save_version(path)

            with open(path, "a", encoding="utf-8") as f:
                f.write(text)

        # Record this as a real mutation in the current skill scope (if any)
        try:
            from agntspace.activity.decisions import note_vault_write
            note_vault_write()
        except Exception:
            pass

        # Scope-write trail — action='modify' (append always on existing file).
        if cid and self._scope_writes is not None:
            self._record_scope_write_if_any(
                relative_path, path, scope_pre_hash,
                scope_history_version, existed_before=True,
            )

        try:
            self._stamp_recent_write(str(path.resolve()), path.stat().st_mtime)
        except OSError:
            pass

    def _is_versionable(self, relative_path: str) -> bool:
        """Check if a file is in a versioned directory."""
        normalized = relative_path.replace("\\", "/")
        if not normalized.endswith(".md"):
            return False
        for prefix in _VERSIONED_DIRS:
            if normalized.startswith(prefix) and "/wiki/" in normalized:
                basename = normalized.split("/")[-1]
                if basename.startswith(("_", ".")) or "/.archives/" in normalized or "/.history/" in normalized:
                    return False
                return True
        return False

    def _save_version(self, path: Path) -> None:
        """Save current file content as a version in .history/.

        Only skips if content is identical to the last saved version (hash match).
        No size thresholds — every real change is versioned.
        """
        try:
            old_content = path.read_text(encoding="utf-8")
        except Exception:
            return

        if not old_content.strip():
            return

        history_dir = path.parent / ".history"
        old_hash = hashlib.md5(old_content.encode()).hexdigest()[:12]

        # Skip if identical to last version
        if history_dir.exists():
            existing = sorted(history_dir.glob(f"{path.stem}_*.md"), reverse=True)
            if existing:
                try:
                    last_hash = hashlib.md5(
                        existing[0].read_text(encoding="utf-8").encode()
                    ).hexdigest()[:12]
                    if old_hash == last_hash:
                        return
                except Exception:
                    pass

        history_dir.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f")
        version_path = history_dir / f"{path.stem}_{timestamp}.md"
        version_path.write_text(old_content, encoding="utf-8")
        log.debug("Version saved: %s", version_path)

        # Keep only last 20 versions per article
        existing = sorted(history_dir.glob(f"{path.stem}_*.md"), reverse=True)
        for old_version in existing[20:]:
            try:
                old_version.unlink()
            except Exception:
                pass

    def _scope_snapshot(self, path: Path, correlation_id: str) -> tuple[str | None, str | None]:
        """Capture a pre-write snapshot for scope-revert.

        Returns ``(pre_hash, history_version_rel)``. Either may be None:

          - If the file doesn't exist yet: (None, None) — this is a
            'create' from the scope's perspective.
          - If it exists: hashes the current content, saves a copy into
            the file's ``.history/`` with a correlation-scoped filename,
            returns (hash, history_path_relative_to_vault).

        Unlike ``_save_version``, this fires on every write inside a
        correlation scope regardless of whether the file is in a
        "versioned directory" — revert has to cover the whole vault.
        """
        try:
            if not path.is_file():
                return (None, None)
            old_content = path.read_text(encoding="utf-8")
        except Exception:
            return (None, None)

        pre_hash = hashlib.md5(old_content.encode("utf-8")).hexdigest()[:12]

        # Scope-tagged snapshot filename — short prefix + timestamp so
        # many scope writes to the same file don't collide and the file
        # is clearly distinguishable from regular ``_save_version`` entries.
        cid_tag = (correlation_id[:8] or "scope").replace("/", "_")
        timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f")
        history_dir = path.parent / ".history"
        try:
            history_dir.mkdir(parents=True, exist_ok=True)
        except OSError:
            return (pre_hash, None)

        snap_path = history_dir / f"{path.stem}_scope-{cid_tag}_{timestamp}.md"
        try:
            snap_path.write_text(old_content, encoding="utf-8")
        except OSError:
            return (pre_hash, None)

        # Stored as vault-relative so revert is portable across vault moves.
        try:
            rel = snap_path.resolve().relative_to(self._vault_dir.resolve())
            return (pre_hash, str(rel).replace("\\", "/"))
        except ValueError:
            return (pre_hash, str(snap_path))

    def _record_scope_write_if_any(
        self,
        relative_path: str,
        path: Path,
        pre_hash: str | None,
        history_version: str | None,
        existed_before: bool,
    ) -> None:
        """Best-effort: record this write to scope_file_writes for later revert.

        No-ops when either the recorder isn't wired (tests, legacy init)
        or we're not inside a correlation scope. Never raises.
        """
        recorder = self._scope_writes
        if recorder is None:
            return
        try:
            from agntspace.activity.decisions import current_correlation_id
            correlation_id = current_correlation_id() or ""
        except Exception:
            correlation_id = ""
        if not correlation_id:
            return

        action = "modify" if existed_before else "create"
        try:
            post_content = path.read_text(encoding="utf-8") if path.is_file() else ""
            post_hash = hashlib.md5(post_content.encode("utf-8")).hexdigest()[:12] if post_content else None
        except Exception:
            post_hash = None

        try:
            recorder.record(
                correlation_id=correlation_id,
                path=relative_path.replace("\\", "/"),
                action=action,
                pre_hash=pre_hash,
                history_version=history_version,
                post_hash=post_hash,
            )
        except Exception:
            # Scope-recording must never break a write that already succeeded.
            log.debug("_record_scope_write_if_any failed", exc_info=True)

    def list_versions(self, relative_path: str) -> list[dict]:
        """List version history for a file."""
        path = self._resolve(relative_path)
        history_dir = path.parent / ".history"
        if not history_dir.is_dir():
            return []

        versions = []
        for vf in sorted(history_dir.glob(f"{path.stem}_*.md"), reverse=True):
            parts = vf.stem.rsplit("_", 1)
            timestamp = parts[-1] if len(parts) >= 2 else ""
            versions.append({
                "version": vf.name,
                "timestamp": timestamp,
                "size": vf.stat().st_size,
                "path": self._paths.to_logical(vf) if self._paths else str(vf.relative_to(self._vault_dir)),
            })
        return versions

    def read_version(self, relative_path: str, version: str) -> str | None:
        """Read a specific version of a file."""
        path = self._resolve(relative_path)
        version_path = path.parent / ".history" / version
        if version_path.is_file():
            return version_path.read_text(encoding="utf-8")
        return None


class _PipelineContext:
    """Context manager that suppresses versioning during pipeline operations."""

    def __init__(self, writer: VaultWriter) -> None:
        self._writer = writer

    def __enter__(self) -> VaultWriter:
        self._writer._pipeline_mode = True
        return self._writer

    def __exit__(self, *args: object) -> None:
        self._writer._pipeline_mode = False


class _TrustedScopeContext:
    """Context manager that sets a default trust_reason for every write
    inside the `with` block. Stackable — on exit, the previous reason
    (if any) is restored.

    This is *not* strictly concurrency-safe: two concurrent callers of
    the same writer instance that both enter a trusted_scope will clash
    on the instance attribute. In practice, VaultWriter is a per-vault
    singleton and writes are serialized through the async event loop,
    so the race is theoretical. A stricter implementation would use a
    contextvar, at the cost of more plumbing; that migration is tracked
    in `docs/threat-model.md` §9.2 follow-ups.
    """

    def __init__(self, writer: VaultWriter, reason: str) -> None:
        self._writer = writer
        self._reason = reason
        self._previous: str | None = None

    def __enter__(self) -> VaultWriter:
        self._previous = self._writer._default_trust_reason
        self._writer._default_trust_reason = self._reason
        return self._writer

    def __exit__(self, *args: object) -> None:
        self._writer._default_trust_reason = self._previous
