"""FileStore — atomic JSON file-backed CRUD store for git-tracked state.

Each FileStore instance wraps a single JSON file containing an array of records.
Writes are atomic (write to .tmp then rename) to prevent corruption.
Thread-safe via a threading lock for single-process concurrent access.

Schema versioning: files are stored as ``{"schema_version": N, "records": [...]}``
so that future format changes can be migrated automatically on load.  Legacy files
that are bare JSON arrays are auto-upgraded to the envelope format on first read.
"""

from __future__ import annotations

import json
import logging
import os
import threading
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Schema migration registry
# ---------------------------------------------------------------------------
# Keyed by ``(store_name, from_version)`` → callable that transforms the
# records list in-place and returns the new version number.
#
# Example registration (done externally, e.g. in ``seed/migrations``):
#   SCHEMA_MIGRATIONS[("kanban", 1)] = lambda recs: (_add_priority_field(recs), 2)[1]
#
SCHEMA_MIGRATIONS: dict[tuple[str, int], Callable[[list[dict[str, Any]]], int]] = {}

# Current target version per store name.  If a store is not listed here it
# defaults to 1 (i.e. the initial version, no migrations required).
CURRENT_SCHEMA_VERSIONS: dict[str, int] = {}


class FileStore:
    """Atomic JSON file-backed store for a single collection of records.

    Each record is a dict with a unique ID field. The backing file is a JSON
    array of these dicts wrapped in a versioned envelope:
    ``{"schema_version": 1, "records": [...]}``.

    All mutations are atomic (write to temp, then rename).
    """

    def __init__(
        self,
        path: Path,
        id_field: str = "id",
        store_name: str | None = None,
    ) -> None:
        self.path = path
        self.id_field = id_field
        # store_name is used for migration lookup; defaults to filename stem
        self.store_name = store_name or path.stem
        self._lock = threading.Lock()
        self._schema_version: int = CURRENT_SCHEMA_VERSIONS.get(self.store_name, 1)
        # Ensure parent directory exists
        self.path.parent.mkdir(parents=True, exist_ok=True)
        # Create the file with an empty envelope if it doesn't exist
        if not self.path.exists():
            self._write([])

    # ── Read ──────────────────────────────────────────────

    def load_all(self) -> list[dict[str, Any]]:
        """Load all records from the JSON file."""
        with self._lock:
            return self._read()

    def get(self, record_id: str) -> dict[str, Any] | None:
        """Get a single record by its ID."""
        with self._lock:
            for record in self._read():
                if record.get(self.id_field) == record_id:
                    return record
            return None

    def query(self, filter_fn: Callable[[dict[str, Any]], bool]) -> list[dict[str, Any]]:
        """Return all records matching the filter function."""
        with self._lock:
            return [r for r in self._read() if filter_fn(r)]

    # ── Write ─────────────────────────────────────────────

    def insert(self, record: dict[str, Any]) -> None:
        """Insert a new record. Raises ValueError if ID already exists."""
        with self._lock:
            records = self._read()
            rid = record.get(self.id_field)
            if rid and any(r.get(self.id_field) == rid for r in records):
                raise ValueError(f"Record with {self.id_field}={rid!r} already exists")
            records.append(record)
            self._write(records)

    def upsert(self, record: dict[str, Any]) -> None:
        """Insert or replace a record (matched by ID field)."""
        with self._lock:
            records = self._read()
            rid = record.get(self.id_field)
            records = [r for r in records if r.get(self.id_field) != rid]
            records.append(record)
            self._write(records)

    def update(self, record_id: str, fields: dict[str, Any]) -> bool:
        """Update specific fields on a record. Returns True if found."""
        with self._lock:
            records = self._read()
            for record in records:
                if record.get(self.id_field) == record_id:
                    record.update(fields)
                    self._write(records)
                    return True
            return False

    def update_where(
        self,
        filter_fn: Callable[[dict[str, Any]], bool],
        fields: dict[str, Any],
    ) -> int:
        """Update fields on all records matching filter. Returns count updated."""
        with self._lock:
            records = self._read()
            count = 0
            for record in records:
                if filter_fn(record):
                    record.update(fields)
                    count += 1
            if count:
                self._write(records)
            return count

    def delete(self, record_id: str) -> bool:
        """Delete a record by ID. Returns True if found."""
        with self._lock:
            records = self._read()
            new_records = [r for r in records if r.get(self.id_field) != record_id]
            if len(new_records) < len(records):
                self._write(new_records)
                return True
            return False

    # ── Bulk ──────────────────────────────────────────────

    def bulk_insert(self, new_records: list[dict[str, Any]]) -> None:
        """Insert multiple records at once (more efficient than repeated insert)."""
        with self._lock:
            records = self._read()
            existing_ids = {r.get(self.id_field) for r in records}
            for rec in new_records:
                rid = rec.get(self.id_field)
                if rid and rid in existing_ids:
                    continue  # skip duplicates silently
                records.append(rec)
                if rid:
                    existing_ids.add(rid)
            self._write(records)

    def replace_all(self, records: list[dict[str, Any]]) -> None:
        """Replace the entire store contents (used by migration)."""
        with self._lock:
            self._write(records)

    # ── Internals ─────────────────────────────────────────

    def _read(self) -> list[dict[str, Any]]:
        """Read the JSON file, apply any pending migrations, return records.

        Handles three on-disk shapes:
        1. Versioned envelope ``{"schema_version": N, "records": [...]}``
        2. Legacy bare array ``[...]`` (auto-upgraded to v1 envelope)
        3. Missing / corrupt → empty list
        """
        try:
            text = self.path.read_text(encoding="utf-8")
            data = json.loads(text)
        except (FileNotFoundError, json.JSONDecodeError):
            return []

        # --- Normalise to envelope ---
        if isinstance(data, list):
            # Legacy bare-array format → treat as version 1
            records = data
            file_version = 1
            # Auto-upgrade the file to envelope format on next write
        elif isinstance(data, dict) and "records" in data:
            records = data["records"] if isinstance(data["records"], list) else []
            file_version = data.get("schema_version", 1)
        else:
            return []

        # --- Apply migrations if needed ---
        target_version = CURRENT_SCHEMA_VERSIONS.get(self.store_name, 1)
        if file_version < target_version:
            records = self._apply_migrations(records, file_version, target_version)
            # Persist the migrated data so we don't re-migrate every read
            self._write(records)

        return records

    def _apply_migrations(
        self,
        records: list[dict[str, Any]],
        from_version: int,
        to_version: int,
    ) -> list[dict[str, Any]]:
        """Run sequential migrations from *from_version* up to *to_version*."""
        current = from_version
        while current < to_version:
            key = (self.store_name, current)
            migration_fn = SCHEMA_MIGRATIONS.get(key)
            if migration_fn is None:
                logger.warning(
                    "No migration registered for %s v%d→v%d; stopping at v%d",
                    self.store_name,
                    current,
                    current + 1,
                    current,
                )
                break
            try:
                new_version = migration_fn(records)
                logger.info(
                    "Migrated %s from v%d → v%d (%d records)",
                    self.store_name,
                    current,
                    new_version,
                    len(records),
                )
                current = new_version
            except Exception:
                logger.exception(
                    "Migration failed for %s v%d; data left at v%d",
                    self.store_name,
                    current,
                    current,
                )
                break
        return records

    def _write(self, records: list[dict[str, Any]]) -> None:
        """Atomically write records inside a versioned envelope."""
        target_version = CURRENT_SCHEMA_VERSIONS.get(self.store_name, 1)
        envelope: dict[str, Any] = {
            "schema_version": target_version,
            "records": records,
        }
        tmp_path = self.path.with_suffix(".tmp")
        text = json.dumps(envelope, indent=2, default=_json_default, ensure_ascii=False)
        tmp_path.write_text(text + "\n", encoding="utf-8")
        # Atomic rename (on Windows, need to remove target first)
        if os.name == "nt" and self.path.exists():
            self.path.unlink()
        tmp_path.rename(self.path)
        # Track mutation time for delta-sync
        self._last_write_ts = datetime.now().timestamp()

    @property
    def last_modified_ts(self) -> float:
        """Return the last-write timestamp (or file mtime as fallback)."""
        ts = getattr(self, "_last_write_ts", None)
        if ts:
            return ts
        try:
            return self.path.stat().st_mtime
        except FileNotFoundError:
            return 0.0

    @property
    def count(self) -> int:
        """Return number of records without loading full list into caller."""
        return len(self._read())


def _json_default(obj: object) -> str:
    """JSON serializer for non-standard types."""
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, Path):
        return str(obj)
    raise TypeError(f"Type {type(obj)} not serializable")
