"""Hybrid defaults sync — transparency-first upgrade gating for shipped
defaults (skills, agents, future artifacts).

Two orthogonal axes decide what happens to each file:

* **Tier** (by path) — engine vs. strategy.
    * `meta` (configured per target) — harness infrastructure (intent regex
      tables, model routing, ontology vocabulary). Silent overwrite on every
      boot because rejecting them breaks the harness. Still audit-trailed:
      every silent change emits a high-importance ActivityLogger event so
      the user can see what happened after the fact.
    * `core` — strategy (personality, prompts, behavior rules). Hash-gated.

* **Classification** (per shipped change, from a shipped release manifest) —
    * `additive` — the file didn't exist in the vault before. Auto-land; no
      user decision to make.
    * `safety` — explicitly marked by the vendor as a safety or bug fix.
      Auto-apply + emit audit event. User can see it in /activity.
    * `behavior` — everything else. Lands on the pending queue for user
      review, **even when the user hasn't edited the vault copy** — their
      running state is still their creation.
    * Missing classification → defaults to `behavior`. Transparency by
      default: if we (the vendor) forget to classify a change, it surfaces
      to the user instead of silently replacing their state.

Runtime hashing (no build step): defaults are bundled in the wheel and
hashed on each boot. Classifications live in `defaults/.release-notes.json`
with keys relative to the defaults root (e.g. `skills/core/kb-ingest/SKILL.md`
or `vault/agents/librarian.md`).

Vault bookkeeping — two small JSON files alongside the synced files:

    <target>/.shipped-manifest.json   — path → accepted shipped hash
    <target>/.pending-upgrades.json   — path → pending entry with full
                                        shipped+vault content for diff
"""

from __future__ import annotations

import hashlib
import json
import logging
import shutil
from dataclasses import dataclass, field
from pathlib import Path

log = logging.getLogger(__name__)

# Files the sync should never touch (its own bookkeeping + anything starting
# with a dot, which are treated as PKM/editor artifacts).
_MANIFEST_FILE = ".shipped-manifest.json"
_PENDING_FILE = ".pending-upgrades.json"
_IGNORE_NAMES = frozenset({_MANIFEST_FILE, _PENDING_FILE, ".gitkeep", ".DS_Store"})

# Default tier classification for SkillSync — see module docstring.
# Kept as a module-level constant so the standalone `classify()` helper
# (exported for backward compatibility + simple callers) behaves the same
# as instantiating the class with defaults.
_SKILL_META_PREFIXES = ("_meta/", "_meta\\")


def classify(rel_path: str) -> str:
    """Return 'meta' for engine-tier skills, 'core' for everything else.

    Standalone helper retained for code that was written against the original
    skills-only API. Instance-level classification lives on `DefaultsSync` and
    is configurable per target (skills vs agents vs future artifacts).
    """
    return "meta" if rel_path.startswith(_SKILL_META_PREFIXES) else "core"


def _hash_bytes(data: bytes) -> str:
    return "sha256:" + hashlib.sha256(data).hexdigest()


def _hash_file(path: Path) -> str:
    return _hash_bytes(path.read_bytes())


def _rel_key(path: Path, base: Path) -> str:
    """Forward-slash relative path — stable across Windows/Unix manifests."""
    return path.relative_to(base).as_posix()


def _is_syncable(path: Path) -> bool:
    """Regular file, not a bookkeeping artifact, not inside a hidden dir."""
    if not path.is_file():
        return False
    if path.name in _IGNORE_NAMES:
        return False
    # Skip anything under a dot-directory (e.g. `.history/`, `.archive/`)
    return not any(part.startswith(".") for part in path.parts)


@dataclass
class PendingUpgrade:
    """A shipped change waiting for user review (either a genuine conflict,
    or an unclassified/behavior change surfaced under Option-B transparency)."""

    path: str
    shipped_hash: str
    shipped_content: str
    accepted_hash: str
    vault_hash: str
    vault_content: str
    classification: str = "behavior"   # "behavior" | "additive" (rare) | "safety"
    vault_edited: bool = False         # True when the user modified their copy


@dataclass
class SyncReport:
    """Summary of a sync_on_boot pass — returned and logged for visibility.

    Lists are mutually exclusive; each path appears in exactly one bucket.
    `upgraded_clean` is reserved for SAFETY-classified silent auto-applies
    (Option B): unclassified or behavior changes land on `pending` instead,
    even when the vault copy was unmodified.
    """

    created: list[str] = field(default_factory=list)          # new files copied in
    overwritten_meta: list[str] = field(default_factory=list) # engine-tier silent update
    upgraded_clean: list[str] = field(default_factory=list)   # safety-classified silent upgrade
    skipped_user_edit: list[str] = field(default_factory=list)# only user changed, shipped unchanged
    pending: list[str] = field(default_factory=list)          # user review required
    errors: list[tuple[str, str]] = field(default_factory=list)

    def summary(self) -> dict:
        return {
            "created": len(self.created),
            "overwritten_meta": len(self.overwritten_meta),
            "upgraded_clean": len(self.upgraded_clean),
            "skipped_user_edit": len(self.skipped_user_edit),
            "pending": len(self.pending),
            "errors": len(self.errors),
        }


# ── Release-notes classification ─────────────────────────────────────────
#
# Shipped at `defaults/.release-notes.json` in the wheel. The file declares
# a classification per changed path, relative to the defaults root. Example:
#
#     {
#       "version": "1.24.0",
#       "changes": {
#         "skills/_meta/local-intents/config/prompt.yaml": "safety",
#         "skills/core/kb-ingest/SKILL.md": "behavior",
#         "vault/agents/librarian.md": "behavior"
#       }
#     }
#
# Missing entries default to `behavior` — by design, so that forgetting to
# classify a shipped change errs on the side of user review, not silent
# application. `additive` is computed at sync time (file didn't exist in
# the vault) rather than declared here.


def _load_release_notes(defaults_root: Path) -> dict[str, str]:
    """Return a flat map of `path-relative-to-defaults-root → classification`.

    Missing file, malformed JSON, and missing keys all degrade to an empty
    dict, which the caller treats as "every modification is `behavior`" —
    the conservative/transparent default.
    """
    notes_path = defaults_root / ".release-notes.json"
    if not notes_path.is_file():
        return {}
    try:
        data = json.loads(notes_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        log.warning("Corrupt .release-notes.json (%s) — defaulting all to behavior", exc)
        return {}
    changes = data.get("changes")
    if not isinstance(changes, dict):
        return {}
    # Normalize to forward slashes so Windows wheels and Unix wheels agree.
    return {str(k).replace("\\", "/"): str(v) for k, v in changes.items()}


# Classification value vocabulary (single source of truth).
_CLASS_SAFETY = "safety"
_CLASS_BEHAVIOR = "behavior"
_ALLOWED_CLASSES = frozenset({_CLASS_SAFETY, _CLASS_BEHAVIOR, "additive"})


def _normalize_classification(value: str) -> str:
    """Map a release-notes value to the internal vocabulary. Unknown values
    degrade to `behavior` (transparent default)."""
    return value if value in _ALLOWED_CLASSES else _CLASS_BEHAVIOR


class DefaultsSync:
    """Sync any shipped-default file tree into a vault directory using hash
    gating. Parameterized by `meta_prefixes` — paths matching any prefix are
    treated as engine-tier (always overwritten); everything else is strategy
    (hash-gated, conflicts land in the pending queue).

    Specializations:
        SkillSync  — meta_prefixes=("_meta/",) — skills under `system/skills/`
        AgentSync  — meta_prefixes=() — agents under `system/agents/` (all
                     agents are user-tunable, no silent-overwrite tier)
    """

    def __init__(
        self,
        vault_dir: Path,
        defaults_dir: Path,
        *,
        meta_prefixes: tuple[str, ...] = (),
        release_notes: dict[str, str] | None = None,
        release_notes_prefix: str = "",
        activity_logger=None,  # noqa: ANN001 — optional, duck-typed
    ) -> None:
        self._vault_dir = vault_dir
        # Kept for backward compat with callers that read `_skills_dir`.
        self._skills_dir = vault_dir
        self._defaults_dir = defaults_dir
        self._manifest_path = vault_dir / _MANIFEST_FILE
        self._pending_path = vault_dir / _PENDING_FILE
        # Normalize: accept "_meta/" and transparently match the Windows
        # backslash variant as well, so callers don't have to think about it.
        norm: list[str] = []
        for p in meta_prefixes:
            norm.append(p)
            if p.endswith("/") and not p.endswith("\\"):
                norm.append(p[:-1] + "\\")
        self._meta_prefixes: tuple[str, ...] = tuple(norm)
        # Release-notes classification: keys in the manifest are relative to
        # the defaults ROOT (e.g. `skills/core/foo.md`), but _sync_one sees
        # paths relative to this target's subtree (`core/foo.md`). The
        # `release_notes_prefix` lets the subclass tell us how to map.
        self._release_notes = release_notes or {}
        self._release_notes_prefix = release_notes_prefix.rstrip("/")
        self._activity_logger = activity_logger

    def _classification_for(self, rel_path: str) -> str:
        """Look up a shipped change's classification from the release notes.

        Missing/unknown → 'behavior' (transparent default, Option B semantics).
        """
        if not self._release_notes:
            return _CLASS_BEHAVIOR
        prefix = f"{self._release_notes_prefix}/" if self._release_notes_prefix else ""
        key = f"{prefix}{rel_path}".replace("\\", "/")
        return _normalize_classification(self._release_notes.get(key, _CLASS_BEHAVIOR))

    def _emit_activity(self, action: str, summary: str, details: dict) -> None:
        """Fire-and-forget audit event — never breaks the sync pass if the
        logger is absent or raises."""
        if not self._activity_logger:
            return
        try:
            self._activity_logger.log(
                category="system",
                action=action,
                summary=summary,
                details=details,
                importance="high",
            )
        except Exception:
            log.exception("sync activity event failed for %s", details.get("path"))

    def _classify(self, rel_path: str) -> str:
        """'meta' when the path matches a configured engine-tier prefix,
        'core' otherwise. Overridable if a subclass needs finer logic."""
        if self._meta_prefixes and rel_path.startswith(self._meta_prefixes):
            return "meta"
        return "core"

    # ── Manifest I/O ─────────────────────────────────────────────────────

    def _load_json(self, path: Path) -> dict:
        if not path.is_file():
            return {}
        try:
            return json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            log.warning("Corrupt %s (%s) — treating as empty", path, exc)
            return {}

    def _save_json(self, path: Path, data: dict) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(path.suffix + ".tmp")
        tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
        tmp.replace(path)

    def _load_manifest(self) -> dict[str, str]:
        return self._load_json(self._manifest_path)

    def _save_manifest(self, manifest: dict[str, str]) -> None:
        self._save_json(self._manifest_path, manifest)

    def _load_pending(self) -> dict[str, dict]:
        return self._load_json(self._pending_path)

    def _save_pending(self, pending: dict[str, dict]) -> None:
        self._save_json(self._pending_path, pending)

    # ── Shipped hashes ───────────────────────────────────────────────────

    def _shipped_files(self) -> dict[str, Path]:
        """Map of relative-path → shipped file on disk."""
        if not self._defaults_dir.is_dir():
            return {}
        out: dict[str, Path] = {}
        for src in self._defaults_dir.rglob("*"):
            if not _is_syncable(src):
                continue
            try:
                rel = _rel_key(src, self._defaults_dir)
            except ValueError:
                continue
            out[rel] = src
        return out

    # ── Public: sync pass ───────────────────────────────────────────────

    def sync_on_boot(self) -> SyncReport:
        """Run the full sync pass. Safe to call on every boot.

        Returns a SyncReport summarizing the actions taken; the caller may
        log it or surface it via ActivityLogger.
        """
        report = SyncReport()
        if not self._defaults_dir.is_dir():
            log.debug("SkillSync: no defaults dir at %s — skipping", self._defaults_dir)
            return report

        self._vault_dir.mkdir(parents=True, exist_ok=True)
        shipped = self._shipped_files()
        manifest = self._load_manifest()
        pending = self._load_pending()

        for rel, src in shipped.items():
            try:
                self._sync_one(rel, src, manifest, pending, report)
            except Exception as exc:  # pragma: no cover - defensive
                log.exception("SkillSync failed for %s", rel)
                report.errors.append((rel, str(exc)))

        self._save_manifest(manifest)
        self._save_pending(pending)
        return report

    # ── Per-file decision logic ──────────────────────────────────────────

    def _sync_one(
        self,
        rel: str,
        src: Path,
        manifest: dict[str, str],
        pending: dict[str, dict],
        report: SyncReport,
    ) -> None:
        shipped_hash = _hash_file(src)
        dst = self._vault_dir / rel
        tier = self._classify(rel)

        # Case 1: vault doesn't have this file yet → copy it in. "Additive"
        # covers fresh installs AND new skills/agents added by a later
        # release. No user decision required: the vault never had this
        # content, so nothing is being altered.
        if not dst.exists():
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dst)
            manifest[rel] = shipped_hash
            pending.pop(rel, None)
            report.created.append(rel)
            return

        vault_hash = _hash_file(dst)
        classification = self._classification_for(rel)

        # Case 2: meta-tier → engine behaviour. Silent overwrite on change
        # (rejecting harness infrastructure would break the harness), but
        # emit a high-importance audit event so the change is still visible
        # to the user in /activity.
        if tier == "meta":
            if vault_hash != shipped_hash:
                shutil.copy2(src, dst)
                report.overwritten_meta.append(rel)
                self._emit_activity(
                    action="defaults_sync_meta_upgrade",
                    summary=f"Harness skill upgraded: {rel}",
                    details={
                        "path": rel,
                        "tier": "meta",
                        "from_hash": vault_hash,
                        "to_hash": shipped_hash,
                    },
                )
            manifest[rel] = shipped_hash
            pending.pop(rel, None)
            return

        # Case 3: core-tier, first time this file is tracked. Bootstrap the
        # manifest without silent overwrites: if the vault already matches
        # shipped, record and move on; if it differs, surface to pending so
        # the user can explicitly decide (no surprise overwrites on the
        # first upgrade after this system landed in an existing vault).
        accepted = manifest.get(rel)
        if accepted is None:
            manifest[rel] = vault_hash
            if vault_hash == shipped_hash:
                return
            pending[rel] = self._pending_entry(
                rel, src, dst, shipped_hash, vault_hash, vault_hash,
                classification=classification, vault_edited=True,
            )
            report.pending.append(rel)
            return

        # Case 4: we've tracked this file before.
        if shipped_hash == accepted:
            # Shipped hasn't moved since the user's last accepted state —
            # any divergence from accepted is the user's own work. Do
            # nothing; clear stale pending entry if one exists.
            pending.pop(rel, None)
            if vault_hash != accepted:
                report.skipped_user_edit.append(rel)
            return

        # Shipped moved. Two sub-cases, decided by classification:
        vault_edited = vault_hash != accepted

        if classification == _CLASS_SAFETY:
            # Vendor-declared safety fix: apply silently (+ emit audit event),
            # regardless of whether the user edited their copy. The user can
            # still roll back from the audit trail if they disagree.
            shutil.copy2(src, dst)
            manifest[rel] = shipped_hash
            pending.pop(rel, None)
            report.upgraded_clean.append(rel)
            self._emit_activity(
                action="defaults_sync_safety_upgrade",
                summary=f"Safety upgrade applied: {rel}",
                details={
                    "path": rel,
                    "tier": tier,
                    "classification": _CLASS_SAFETY,
                    "from_hash": vault_hash,
                    "to_hash": shipped_hash,
                    "vault_edited": vault_edited,
                },
            )
            return

        # Everything else (behavior, missing classification) → pending queue,
        # even when the user hasn't edited their copy. This is the Option B
        # transparency contract: modifications to a user's running state
        # always surface for explicit accept/reject.
        pending[rel] = self._pending_entry(
            rel, src, dst, shipped_hash, vault_hash, accepted,
            classification=classification, vault_edited=vault_edited,
        )
        report.pending.append(rel)

    def _pending_entry(
        self,
        rel: str,
        src: Path,
        dst: Path,
        shipped_hash: str,
        vault_hash: str,
        accepted_hash: str,
        *,
        classification: str = _CLASS_BEHAVIOR,
        vault_edited: bool = False,
    ) -> dict:
        def _text(p: Path) -> str:
            try:
                return p.read_text(encoding="utf-8")
            except (OSError, UnicodeDecodeError):
                return ""
        return {
            "path": rel,
            "tier": self._classify(rel),
            "classification": classification,
            "vault_edited": vault_edited,
            "shipped_hash": shipped_hash,
            "accepted_hash": accepted_hash,
            "vault_hash": vault_hash,
            "shipped_content": _text(src),
            "vault_content": _text(dst),
        }

    # ── Public: read/write for API + CLI ────────────────────────────────

    def status(self) -> dict:
        """Counts without running a sync pass — safe for dashboards."""
        manifest = self._load_manifest()
        pending = self._load_pending()
        shipped = self._shipped_files()
        tracked = len(manifest)
        return {
            "shipped_count": len(shipped),
            "tracked": tracked,
            "pending_count": len(pending),
            "pending_paths": sorted(pending.keys()),
        }

    def list_pending(self) -> list[PendingUpgrade]:
        pending = self._load_pending()
        out: list[PendingUpgrade] = []
        for rel, entry in pending.items():
            out.append(
                PendingUpgrade(
                    path=rel,
                    shipped_hash=entry.get("shipped_hash", ""),
                    shipped_content=entry.get("shipped_content", ""),
                    accepted_hash=entry.get("accepted_hash", ""),
                    vault_hash=entry.get("vault_hash", ""),
                    vault_content=entry.get("vault_content", ""),
                    classification=entry.get("classification", _CLASS_BEHAVIOR),
                    vault_edited=bool(entry.get("vault_edited", False)),
                )
            )
        out.sort(key=lambda p: p.path)
        return out

    def accept_upgrade(self, rel: str) -> bool:
        """Overwrite vault with the shipped version; update manifest."""
        pending = self._load_pending()
        entry = pending.get(rel)
        if not entry:
            return False
        shipped = self._defaults_dir / rel
        if not shipped.is_file():
            pending.pop(rel, None)
            self._save_pending(pending)
            return False
        dst = self._vault_dir / rel
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(shipped, dst)

        manifest = self._load_manifest()
        manifest[rel] = entry.get("shipped_hash") or _hash_file(shipped)
        self._save_manifest(manifest)

        pending.pop(rel, None)
        self._save_pending(pending)
        return True

    def keep_mine(self, rel: str) -> bool:
        """User chose their edit over shipped; move manifest pointer to
        whatever is currently in the vault."""
        pending = self._load_pending()
        if rel not in pending:
            return False
        dst = self._vault_dir / rel
        if not dst.is_file():
            pending.pop(rel, None)
            self._save_pending(pending)
            return False

        manifest = self._load_manifest()
        # Pin to the SHIPPED hash the user was presented with — so that on
        # subsequent syncs (shipped unchanged), `shipped_hash == accepted`
        # fires and the sync reports `skipped_user_edit` instead of
        # re-prompting. If shipped moves to v3 later, the diff re-surfaces
        # naturally. Pinning to the vault hash instead would cause the next
        # sync to silently re-apply shipped v2 under the old semantics, or
        # re-surface a pending that the user already decided on under the
        # new Option B semantics — both are wrong UX.
        entry = pending.get(rel, {})
        shipped_hash = entry.get("shipped_hash") or _hash_file(dst)
        manifest[rel] = shipped_hash
        self._save_manifest(manifest)

        pending.pop(rel, None)
        self._save_pending(pending)
        return True


# ── Specializations ──────────────────────────────────────────────────────
#
# Thin wrappers that bake in the per-target `meta_prefixes` so callers don't
# have to remember which artifact class gets which silent-overwrite policy.


class SkillSync(DefaultsSync):
    """Sync shipped `defaults/skills/` into the vault's `system/skills/`.

    Engine tier: anything under `_meta/*` (harness behavior — tool routing,
    intent matching, ontology, model routing, prompts the agent must follow
    exactly). Strategy tier: everything else (core, creative, work, custom)
    — hash-gated, user edits respected.
    """

    def __init__(
        self,
        skills_dir: Path,
        defaults_dir: Path,
        *,
        release_notes: dict[str, str] | None = None,
        activity_logger=None,  # noqa: ANN001
    ) -> None:
        super().__init__(
            skills_dir,
            defaults_dir,
            meta_prefixes=("_meta/",),
            release_notes=release_notes,
            # Release notes live at defaults/.release-notes.json with keys
            # rooted at the defaults top-level, so skills paths are prefixed
            # with `skills/` there.
            release_notes_prefix="skills",
            activity_logger=activity_logger,
        )


class AgentSync(DefaultsSync):
    """Sync shipped `defaults/vault/agents/` into the vault's `system/agents/`.

    Agents ship with personality + structural metadata (`team`, `workflows`,
    `access_tier`, `model_tier`, `skills`). All of it is user-tunable — a
    user might add a skill to the librarian, narrow an access tier, or
    customize a personality paragraph. So there is no meta tier; every agent
    is hash-gated. New shipped agents (e.g. `healer` was added in a later
    release) auto-land on existing vaults via the "vault doesn't have it"
    branch in the base class.
    """

    def __init__(
        self,
        agents_dir: Path,
        defaults_dir: Path,
        *,
        release_notes: dict[str, str] | None = None,
        activity_logger=None,  # noqa: ANN001
    ) -> None:
        super().__init__(
            agents_dir,
            defaults_dir,
            meta_prefixes=(),
            release_notes=release_notes,
            # Agents live at defaults/vault/agents/, so the release-notes
            # keys are prefixed with `vault/agents/`.
            release_notes_prefix="vault/agents",
            activity_logger=activity_logger,
        )
