"""UKO Graph Persistence — persist and restore UKO graph state.

Provides :class:`UKOGraphPersistence`, a service that serialises the
in-memory UKO graph (triples stored in a :class:`GraphIndexBackend`)
to a JSON file and restores it on application restart.

This satisfies the acceptance criterion: "UKO data persists across
application restarts."

The persistence format is a JSON file containing:

.. code-block:: json

    {
        "version": "1",
        "project": "local/my-app",
        "triples": [
            {"subject": "...", "predicate": "...", "object": "..."}
        ]
    }

Based on ``docs/specification.md`` §20568 — UKO classification persists
across sessions.

ISSUES CLOSED: #891
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Protocol, runtime_checkable

import structlog

from cleveragents.domain.models.acms.index_backends import GraphIndexBackend

logger = structlog.get_logger(__name__)

__all__ = [
    "UKOGraphPersistence",
    "UKOPersistenceBackend",
]

_PERSISTENCE_VERSION = "1"


# ---------------------------------------------------------------------------
# Protocol for persistence backends
# ---------------------------------------------------------------------------


@runtime_checkable
class UKOPersistenceBackend(Protocol):
    """Protocol for UKO graph persistence backends.

    Implementations may write to a local file, a database, or a remote
    store.  The default implementation uses a JSON file.
    """

    def save(self, project: str, triples: list[dict[str, str]]) -> None:
        """Persist *triples* for *project*.

        Args:
            project: Namespaced project name.
            triples: List of ``{"subject": ..., "predicate": ...,
                "object": ...}`` dicts.

        Raises:
            ValueError: If *project* is empty or whitespace-only.
            OSError: If the persistence backend cannot be written.
        """
        ...

    def load(self, project: str) -> list[dict[str, str]]:
        """Load persisted triples for *project*.

        Args:
            project: Namespaced project name.

        Returns:
            List of ``{"subject": ..., "predicate": ..., "object": ...}``
            dicts.  Returns an empty list if no data is persisted.

        Raises:
            ValueError: If *project* is empty or whitespace-only.
        """
        ...


# ---------------------------------------------------------------------------
# JSON file persistence backend
# ---------------------------------------------------------------------------


class JSONFilePersistenceBackend:
    """Persist UKO graph triples to a JSON file.

    Args:
        base_dir: Directory where persistence files are stored.
            Defaults to the current working directory.
    """

    def __init__(self, base_dir: str | Path = ".") -> None:
        self._base_dir = Path(base_dir)

    def _path_for(self, project: str) -> Path:
        """Return the file path for *project*."""
        safe_name = project.replace("/", "_").replace(":", "_")
        return self._base_dir / f"uko_graph_{safe_name}.json"

    def save(self, project: str, triples: list[dict[str, str]]) -> None:
        """Persist *triples* to a JSON file."""
        if not project or not project.strip():
            raise ValueError("project must be a non-empty string")
        self._base_dir.mkdir(parents=True, exist_ok=True)
        path = self._path_for(project)
        data = {
            "version": _PERSISTENCE_VERSION,
            "project": project,
            "triples": triples,
        }
        path.write_text(json.dumps(data, indent=2), encoding="utf-8")
        logger.debug(
            "uko_persistence.saved",
            project=project,
            triple_count=len(triples),
            path=str(path),
        )

    def load(self, project: str) -> list[dict[str, str]]:
        """Load persisted triples from a JSON file."""
        if not project or not project.strip():
            raise ValueError("project must be a non-empty string")
        path = self._path_for(project)
        if not path.exists():
            return []
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
            triples = data.get("triples", [])
            logger.debug(
                "uko_persistence.loaded",
                project=project,
                triple_count=len(triples),
                path=str(path),
            )
            return triples
        except (json.JSONDecodeError, KeyError, OSError) as exc:
            logger.warning(
                "uko_persistence.load_failed",
                project=project,
                error=str(exc),
            )
            return []


# ---------------------------------------------------------------------------
# In-memory persistence backend (for testing)
# ---------------------------------------------------------------------------


class InMemoryPersistenceBackend:
    """In-memory persistence backend for testing.

    Stores triples in a dict keyed by project name.  Data is lost when
    the process exits.
    """

    def __init__(self) -> None:
        self._store: dict[str, list[dict[str, str]]] = {}

    def save(self, project: str, triples: list[dict[str, str]]) -> None:
        """Store *triples* in memory."""
        if not project or not project.strip():
            raise ValueError("project must be a non-empty string")
        self._store[project] = list(triples)

    def load(self, project: str) -> list[dict[str, str]]:
        """Load stored triples from memory."""
        if not project or not project.strip():
            raise ValueError("project must be a non-empty string")
        return list(self._store.get(project, []))

    def clear(self, project: str) -> None:
        """Clear stored triples for *project*."""
        self._store.pop(project, None)


# ---------------------------------------------------------------------------
# UKOGraphPersistence service
# ---------------------------------------------------------------------------


class UKOGraphPersistence:
    """Service for persisting and restoring UKO graph state.

    Serialises the triples stored in a :class:`GraphIndexBackend` to a
    persistence backend (default: JSON file) and restores them on
    application restart.

    Args:
        graph_backend: The graph index backend to persist.
        persistence_backend: Backend for serialisation.  Defaults to
            :class:`InMemoryPersistenceBackend` (suitable for testing).
        project: Namespaced project name.
    """

    def __init__(
        self,
        graph_backend: GraphIndexBackend,
        project: str,
        persistence_backend: UKOPersistenceBackend | None = None,
    ) -> None:
        if not isinstance(graph_backend, GraphIndexBackend):
            raise TypeError(
                "graph_backend must satisfy GraphIndexBackend, "
                f"got {type(graph_backend).__name__}"
            )
        if not project or not project.strip():
            raise ValueError("project must be a non-empty string")
        self._graph_backend = graph_backend
        self._project = project
        self._persistence_backend: UKOPersistenceBackend = (
            persistence_backend or InMemoryPersistenceBackend()
        )
        logger.debug(
            "uko_persistence.service_initialized",
            project=project,
        )

    @property
    def project(self) -> str:
        """The namespaced project name."""
        return self._project

    def save(self) -> int:
        """Persist the current graph state.

        Queries all triples from the graph backend and writes them to
        the persistence backend.

        Returns:
            Number of triples persisted.

        Raises:
            OSError: If the persistence backend cannot be written.
        """
        log = logger.bind(project=self._project)
        log.info("uko_persistence.save.start")

        try:
            bindings = self._graph_backend.query(
                self._project,
                "SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
            )
        except Exception as exc:
            log.warning("uko_persistence.save.query_failed", error=str(exc))
            return 0

        triples = [
            {
                "subject": b.get("s", ""),
                "predicate": b.get("p", ""),
                "object": b.get("o", ""),
            }
            for b in bindings
            if b.get("s") and b.get("p") and b.get("o")
        ]

        self._persistence_backend.save(self._project, triples)

        log.info(
            "uko_persistence.save.done",
            triple_count=len(triples),
        )
        return len(triples)

    def restore(self) -> int:
        """Restore graph state from the persistence backend.

        Loads persisted triples and re-inserts them into the graph
        backend.  Skips triples that fail validation.

        Returns:
            Number of triples restored.
        """
        log = logger.bind(project=self._project)
        log.info("uko_persistence.restore.start")

        triples = self._persistence_backend.load(self._project)
        if not triples:
            log.info("uko_persistence.restore.no_data")
            return 0

        restored = 0
        for triple in triples:
            subject = triple.get("subject", "")
            predicate = triple.get("predicate", "")
            obj = triple.get("object", "")
            if not subject or not predicate or not obj:
                continue
            try:
                self._graph_backend.add_triple(
                    self._project,
                    subject,
                    predicate,
                    obj,
                )
                restored += 1
            except Exception as exc:
                log.warning(
                    "uko_persistence.restore.triple_failed",
                    subject=subject,
                    predicate=predicate,
                    error=str(exc),
                )

        log.info(
            "uko_persistence.restore.done",
            restored=restored,
            total=len(triples),
        )
        return restored
