"""
DuckDB-backed RDF Quad Store for CogniFlow semantics.

The store persists RDF terms in a normalized `rdf_quads` table and keeps a small
catalog of known graphs with merged document context metadata in `graphs`.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
import hashlib
import json
import logging
from urllib.parse import quote

import duckdb
from rdflib import BNode, Dataset, Graph, Literal, URIRef

from .namespaces import CF_BASE, canonical_context
from .rdf_document import DocumentInput, load_rdf_document_input

logger = logging.getLogger(__name__)


_KIND_IRI = "iri"
_KIND_BNODE = "bnode"
_KIND_LITERAL = "literal"
_XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"
_GRAPH_IRI_BASE = f"{CF_BASE}graph/"
_STRUCTURED_FORMAT = "json" + "-ld"
_TYPED_VALUE_PREDICATES = {
    "https://cogniflow.odea-project.org/cf#defaultValue",
    "https://cogniflow.odea-project.org/cf#value",
    "https://cogniflow.odea-project.org/cf#minValue",
    "https://cogniflow.odea-project.org/cf#maxValue",
}


def _coerce_json(value: Any) -> Optional[Dict[str, Any]]:
    """
    Coerce DuckDB/driver JSON values to a Python dict.

    DuckDB JSON columns may come back as:
    - dict (already decoded)
    - str (JSON text)
    - a JSONValue-ish object that stringifies to JSON
    """
    if value is None:
        return None
    if isinstance(value, dict):
        return value
    if isinstance(value, str):
        try:
            parsed = json.loads(value)
            return parsed if isinstance(parsed, dict) else None
        except Exception:  # pylint: disable=broad-exception-caught
            return None
    try:
        parsed = json.loads(str(value))
        return parsed if isinstance(parsed, dict) else None
    except Exception:  # pylint: disable=broad-exception-caught
        return None


def _canonical_json(value: Any) -> str:
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def _sha256_hex(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def _normalize_context(value: Any, *, base_path: Optional[Path]) -> Dict[str, Any]:
    """
    Resolve an RDF document @context value into a merged dict.

    Supports:
    - dict contexts (merged by key)
    - list contexts (recursively)
    - string context references (best-effort local file resolution)
    """
    merged: Dict[str, Any] = {}

    def _merge_dict(d: Dict[str, Any]) -> None:
        for key, entry in d.items():
            if key in merged and merged[key] != entry:
                logger.warning(
                    "RDF document context conflict for '%s': %s != %s", key, merged[key], entry
                )
                continue
            merged[key] = entry

    def _handle(obj: Any) -> None:
        if obj is None:
            return
        if isinstance(obj, dict):
            _merge_dict(obj)
            return
        if isinstance(obj, list):
            for item in obj:
                _handle(item)
            return
        if isinstance(obj, str):
            if base_path:
                candidate = Path(obj)
                if not candidate.is_absolute():
                    candidate = (base_path / candidate).resolve()
                if candidate.is_file():
                    try:
                        data = json.loads(candidate.read_text(encoding="utf-8"))
                        _handle(data.get("@context"))
                        return
                    except Exception as exc:  # pylint: disable=broad-exception-caught
                        logger.debug("Failed to load referenced context %s: %s", obj, exc)
            # URL contexts are intentionally not fetched.
            return

    _handle(value)
    return merged


def _load_packaged_cf_context() -> Dict[str, Any]:
    return {}


def _graph_id_to_nquads_iri(graph_id: str) -> URIRef:
    if graph_id.startswith("http://") or graph_id.startswith("https://"):
        return URIRef(graph_id)
    return URIRef(f"{_GRAPH_IRI_BASE}{quote(graph_id, safe='')}")


def _has_type(node: Dict[str, Any], candidates: set[str]) -> bool:
    raw = node.get("@type")
    types: List[str] = []
    if isinstance(raw, str):
        types.append(raw)
    elif isinstance(raw, list):
        for entry in raw:
            if isinstance(entry, str):
                types.append(entry)
            elif isinstance(entry, dict):
                entry_id = entry.get("@id")
                if isinstance(entry_id, str):
                    types.append(entry_id)
    elif isinstance(raw, dict):
        entry_id = raw.get("@id")
        if isinstance(entry_id, str):
            types.append(entry_id)
    return any(t in candidates for t in types)


def _extract_step_ids(doc: Dict[str, Any]) -> List[str]:
    step_ids: List[str] = []
    graph = doc.get("@graph")
    nodes = graph if isinstance(graph, list) else [doc]
    for node in nodes:
        if not isinstance(node, dict):
            continue
        if not _has_type(node, {"cf:ProcessingStep"}):
            continue
        node_id = node.get("@id")
        if isinstance(node_id, str):
            step_ids.append(node_id)
    return step_ids


def _extract_step_subgraph(doc: Dict[str, Any], step_id: str) -> Optional[Dict[str, Any]]:
    graph = doc.get("@graph")
    nodes = graph if isinstance(graph, list) else [doc]
    id_index: Dict[str, Dict[str, Any]] = {}
    for node in nodes:
        if not isinstance(node, dict):
            continue
        node_id = node.get("@id")
        if isinstance(node_id, str):
            id_index[node_id] = node

    if step_id not in id_index:
        return None

    def _collect_ids(value: Any, collected: set[str]) -> None:
        if isinstance(value, dict):
            ref_id = value.get("@id")
            if isinstance(ref_id, str):
                collected.add(ref_id)
            for v in value.values():
                _collect_ids(v, collected)
        elif isinstance(value, list):
            for item in value:
                _collect_ids(item, collected)

    visited: set[str] = set()
    queue: List[str] = [step_id]
    subgraph: List[Dict[str, Any]] = []

    while queue:
        current_id = queue.pop(0)
        if current_id in visited:
            continue
        visited.add(current_id)
        node = id_index.get(current_id)
        if not node:
            continue
        subgraph.append(node)
        referenced: set[str] = set()
        _collect_ids(node, referenced)
        for ref_id in referenced:
            if ref_id not in visited and ref_id in id_index:
                queue.append(ref_id)

    return {"@context": doc.get("@context", {}), "@graph": subgraph}


def _normalize_ingest_document(document: Dict[str, Any]) -> Dict[str, Any]:
    """
    Normalize known RDF document payload patterns before RDF ingestion.

    Step parameters can declare JSON defaults as plain JSON objects/arrays:
      "cf:defaultValue": {"a": 1}
    Without explicit JSON typing, these parse as empty blank nodes in RDF.
    We convert such values to `rdf:JSON` typed literals so the value round-trips.
    """
    payload: Dict[str, Any] = json.loads(json.dumps(document, ensure_ascii=False))
    graph = payload.get("@graph")
    nodes: List[Any] = graph if isinstance(graph, list) else [payload]

    default_keys = {
        "cf:defaultValue",
        f"{CF_BASE}defaultValue",
    }
    value_keys = {
        "cf:value",
        f"{CF_BASE}value",
    }
    json_payload_keys = {
        "cf:operationContract",
        f"{CF_BASE}operationContract",
        "cf:pluginCertification",
        f"{CF_BASE}pluginCertification",
        "cf:pluginCertificate",
        f"{CF_BASE}pluginCertificate",
    }
    param_types = {
        "cf:Parameter",
        f"{CF_BASE}Parameter",
    }
    parameter_binding_types = {
        "cf:ParameterBinding",
        f"{CF_BASE}ParameterBinding",
    }
    json_parameter_type_values = {
        "rdf:JSON",
        "http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON",
    }
    value_contract_keys = {
        "cf:valueContract",
        f"{CF_BASE}valueContract",
    }
    element_type_keys = {
        "cf:elementType",
        f"{CF_BASE}elementType",
    }
    id_index = {
        node["@id"]: node
        for node in nodes
        if isinstance(node, dict) and isinstance(node.get("@id"), str)
    }

    def _is_value_object(value: Any) -> bool:
        return isinstance(value, dict) and any(str(k).startswith("@") for k in value.keys())

    def _normalize_default_value(value: Any) -> Any:
        if isinstance(value, list):
            return [_normalize_default_value(item) for item in value]
        if isinstance(value, (dict, list)):
            if _is_value_object(value):
                return value
            return {
                "@value": json.dumps(value, ensure_ascii=False, sort_keys=True),
                "@type": "rdf:JSON",
            }
        if isinstance(value, str):
            text = value.strip()
            if not text:
                return value
            if text.startswith("{") or text.startswith("["):
                try:
                    parsed = json.loads(text)
                    return {
                        "@value": json.dumps(parsed, ensure_ascii=False, sort_keys=True),
                        "@type": "rdf:JSON",
                    }
                except Exception:  # pylint: disable=broad-exception-caught
                    return value
        return value

    def _is_json_parameter(node: Dict[str, Any]) -> bool:
        for key in value_contract_keys:
            raw = node.get(key)
            contract_id = raw.get("@id") if isinstance(raw, dict) else raw
            if not isinstance(contract_id, str):
                continue
            contract = id_index.get(contract_id)
            if not isinstance(contract, dict):
                continue
            for element_key in element_type_keys:
                value_raw = contract.get(element_key)
                value = value_raw.get("@id") if isinstance(value_raw, dict) else value_raw
                if isinstance(value, str) and value in json_parameter_type_values:
                    return True
        return False

    for node in nodes:
        if not isinstance(node, dict):
            continue
        for key in list(node.keys()):
            if key in json_payload_keys:
                node[key] = _normalize_default_value(node[key])
        if _has_type(node, parameter_binding_types):
            for key in list(node.keys()):
                if key in value_keys:
                    node[key] = _normalize_default_value(node[key])
        if not _has_type(node, param_types):
            continue
        if _is_json_parameter(node):
            for key in list(node.keys()):
                if key in default_keys:
                    node[key] = _normalize_default_value(node[key])

    return payload


def _sql_placeholders(values: Sequence[Any]) -> str:
    return ", ".join(["?"] * len(values))


def _default_graph_kind(graph_id: str) -> str:
    if graph_id in {"core", "shapes"}:
        return "system"
    if graph_id.startswith("pipe:"):
        return "pipeline_rev"
    if graph_id.startswith("pkg:"):
        return "package_step" if "#" in graph_id else "package"
    return "custom"


def _expand_curie(value: str, context: Dict[str, Any]) -> str:
    text = str(value or "").strip()
    if not text:
        return text
    if "://" in text or text.startswith("urn:"):
        return text
    if ":" not in text:
        return text
    prefix, local = text.split(":", 1)
    ns = context.get(prefix)
    if isinstance(ns, str) and ns:
        return f"{ns}{local}"
    return text


def _compact_iri(iri: str, context: Dict[str, Any]) -> str:
    text = str(iri or "").strip()
    if not text:
        return text
    pairs: List[Tuple[str, str]] = []
    for prefix, ns in context.items():
        if isinstance(prefix, str) and isinstance(ns, str) and ns:
            pairs.append((prefix, ns))
    pairs.sort(key=lambda item: len(item[1]), reverse=True)
    for prefix, ns in pairs:
        if text.startswith(ns):
            local = text[len(ns):]
            if local:
                return f"{prefix}:{local}"
    return text


def _local_iri(iri: str) -> str:
    text = str(iri or "").strip()
    if not text:
        return text
    if "#" in text:
        return text.split("#", 1)[1]
    if "/" in text:
        return text.rsplit("/", 1)[-1]
    if ":" in text:
        return text.split(":", 1)[1]
    return text


def _graph_alias_metadata(
    graph_id: str, context: Dict[str, Any]
) -> Tuple[str, str, str, List[str]]:
    raw_graph_id = str(graph_id)
    if raw_graph_id.startswith("pkg:") and "#" in raw_graph_id:
        lookup = raw_graph_id.split("#", 1)[1]
    else:
        lookup = raw_graph_id
    canonical = _expand_curie(lookup, context)
    compact = _compact_iri(canonical, context)
    local = _local_iri(canonical)
    aliases: List[str] = []
    for value in (raw_graph_id, lookup, canonical, compact, local):
        text = str(value or "").strip()
        if text and text not in aliases:
            aliases.append(text)
    return canonical, compact, local, aliases


@dataclass(frozen=True)
class GraphCatalogEntry:
    """Catalog row describing one named graph persisted in DuckDB."""

    graph_id: str
    graph_type: str
    package: str
    path: Optional[str]
    context: Dict[str, Any]
    graph_kind: str
    is_active: bool
    content_hash: Optional[str]
    package_name: Optional[str]
    package_version: Optional[str]
    canonical_id: Optional[str]
    compact_id: Optional[str]
    local_id: Optional[str]
    aliases: List[str]


class QuadStore:
    """
    A minimal RDF quad store backed by DuckDB.

    Quads are stored in `rdf_quads` with normalized RDF term columns. Each ingested
    RDF document is mapped to a single named graph (`g = graph_id`).
    """

    def __init__(self, db_path: Path, semantics_dir: Path, read_only: bool = False):
        self.db_path = Path(db_path)
        self.semantics_dir = Path(semantics_dir)
        self.read_only = bool(read_only)

    def _connect(self, *, read_only: Optional[bool] = None) -> duckdb.DuckDBPyConnection:
        ro = self.read_only if read_only is None else bool(read_only)
        return duckdb.connect(str(self.db_path), read_only=ro)

    def init_schema(self, *, create_pipeline_tables: bool = True) -> None:
        """Create or migrate required tables/columns for the quad store."""
        if not self.read_only:
            self.semantics_dir.mkdir(parents=True, exist_ok=True)

        con = self._connect(read_only=False)
        try:
            con.execute(
                """
                CREATE TABLE IF NOT EXISTS graphs (
                  graph_id   TEXT PRIMARY KEY,
                  graph_type TEXT,
                  package    TEXT,
                  path       TEXT,
                  context    JSON,
                  graph_kind TEXT,
                  is_active  BOOLEAN DEFAULT TRUE,
                  content_hash TEXT,
                  package_name TEXT,
                  package_version TEXT,
                  canonical_id TEXT,
                  compact_id TEXT,
                  local_id TEXT,
                  aliases_json JSON,
                  created_at TIMESTAMP,
                  updated_at TIMESTAMP
                )
                """
            )
            con.execute(
                """
                CREATE TABLE IF NOT EXISTS rdf_quads (
                  g          TEXT,
                  s          TEXT,
                  p          TEXT,
                  o          TEXT,
                  o_kind     TEXT,
                  o_datatype TEXT,
                  o_lang     TEXT,
                  s_kind     TEXT,
                  p_kind     TEXT,
                  graph_type TEXT,
                  package    TEXT,
                  path       TEXT,
                  updated_at TIMESTAMP
                )
                """
            )
            if create_pipeline_tables:
                con.execute(
                    """
                    CREATE TABLE IF NOT EXISTS pipelines (
                      pipeline_id TEXT PRIMARY KEY,
                      current_rev INTEGER NOT NULL,
                      created_at TIMESTAMP,
                      updated_at TIMESTAMP,
                      created_by TEXT,
                      updated_by TEXT
                    )
                    """
                )
                con.execute(
                    """
                    CREATE TABLE IF NOT EXISTS pipeline_revisions (
                      pipeline_id TEXT NOT NULL,
                      rev INTEGER NOT NULL,
                      graph_id TEXT NOT NULL,
                      created_at TIMESTAMP,
                      created_by TEXT,
                      message TEXT,
                      content_hash TEXT,
                      parent_rev INTEGER,
                      PRIMARY KEY (pipeline_id, rev)
                    )
                    """
                )

            graph_columns = {
                row[1] for row in con.execute("PRAGMA table_info('graphs')").fetchall()
            }
            if "context" not in graph_columns:
                con.execute("ALTER TABLE graphs ADD COLUMN context JSON")
            for col, col_type in (
                ("graph_kind", "TEXT"),
                ("is_active", "BOOLEAN DEFAULT TRUE"),
                ("content_hash", "TEXT"),
                ("package_name", "TEXT"),
                ("package_version", "TEXT"),
                ("canonical_id", "TEXT"),
                ("compact_id", "TEXT"),
                ("local_id", "TEXT"),
                ("aliases_json", "JSON"),
                ("created_at", "TIMESTAMP"),
            ):
                if col not in graph_columns:
                    con.execute(f"ALTER TABLE graphs ADD COLUMN {col} {col_type}")

            # Refresh columns after potential ALTERs.
            graph_columns = {
                row[1] for row in con.execute("PRAGMA table_info('graphs')").fetchall()
            }

            # Backfill defaults for existing rows (DuckDB may set NULL on ALTER).
            con.execute("UPDATE graphs SET is_active = TRUE WHERE is_active IS NULL")
            con.execute(
                "UPDATE graphs "
                "SET created_at = updated_at "
                "WHERE created_at IS NULL AND updated_at IS NOT NULL"
            )
            con.execute("UPDATE graphs SET created_at = current_timestamp WHERE created_at IS NULL")
            con.execute(
                """
                UPDATE graphs
                SET graph_kind = CASE
                  WHEN graph_id IN ('core', 'shapes') THEN 'system'
                  WHEN graph_id LIKE 'pipe:%' THEN 'pipeline_rev'
                  WHEN graph_id LIKE 'pkg:%' THEN 'package_step'
                  ELSE 'custom'
                END
                WHERE graph_kind IS NULL
                """
            )

            quad_columns = {
                row[1]
                for row in con.execute("PRAGMA table_info('rdf_quads')").fetchall()
            }
            for col, col_type in (
                ("graph_type", "TEXT"),
                ("package", "TEXT"),
                ("path", "TEXT"),
            ):
                if col not in quad_columns:
                    con.execute(f"ALTER TABLE rdf_quads ADD COLUMN {col} {col_type}")

            if not create_pipeline_tables:
                tables = {row[0] for row in con.execute("PRAGMA show_tables").fetchall()}
                if "pipeline_revisions" in tables:
                    count = con.execute("SELECT COUNT(*) FROM pipeline_revisions").fetchone()[0]
                    if count == 0:
                        con.execute("DROP TABLE pipeline_revisions")
                if "pipelines" in tables:
                    count = con.execute("SELECT COUNT(*) FROM pipelines").fetchone()[0]
                    if count == 0:
                        con.execute("DROP TABLE pipelines")
        finally:
            con.close()

    # ------------------------------------------------------------------ #
    # MARK: Ingestion / Mutation
    # ------------------------------------------------------------------ #

    def clear_graph(self, graph_id: str) -> None:
        """Delete one graph and all its quads atomically."""
        con = self._connect(read_only=False)
        try:
            con.execute("BEGIN TRANSACTION")
            con.execute("DELETE FROM rdf_quads WHERE g = ?", [graph_id])
            con.execute("DELETE FROM graphs WHERE graph_id = ?", [graph_id])
            con.execute("COMMIT")
        except Exception:
            con.execute("ROLLBACK")
            raise
        finally:
            con.close()

    def ingest_rdf_document(
        self,
        path: Path,
        graph_id: str,
        graph_type: str,
        package: str,
        source_path: Optional[str] = None,
        split_steps: bool = False,
        graph_kind: Optional[str] = None,
        is_active: bool = True,
        package_name: Optional[str] = None,
        package_version: Optional[str] = None,
    ) -> None:
        """
        Ingest an RDF document file into the store.

        When `split_steps=True`, the input is split into per-step subgraphs (based on
        `cf:ProcessingStep` nodes). Each resulting subgraph is stored under `g=<step_id>`.
        """
        from .rdf_document import load_rdf_document

        doc = load_rdf_document(Path(path), label="RDF document")
        base_path = Path(path).resolve().parent

        if split_steps:
            for step_id in _extract_step_ids(doc):
                subgraph = _extract_step_subgraph(doc, step_id)
                if not subgraph:
                    continue
                self.ingest_document(
                    subgraph,
                    graph_id=step_id,
                    graph_type=graph_type,
                    package=package,
                    source_path=source_path or str(path),
                    base_path=base_path,
                    graph_kind=graph_kind,
                    is_active=is_active,
                    package_name=package_name,
                    package_version=package_version,
                )
            return

        self.ingest_document(
            doc,
            graph_id=graph_id,
            graph_type=graph_type,
            package=package,
            source_path=source_path or str(path),
            base_path=base_path,
            graph_kind=graph_kind,
            is_active=is_active,
            package_name=package_name,
            package_version=package_version,
        )

    def ingest_document(
        self,
        document: Dict[str, Any],
        *,
        graph_id: str,
        graph_type: str,
        package: str,
        source_path: Optional[str],
        base_path: Optional[Path] = None,
        graph_kind: Optional[str] = None,
        is_active: bool = True,
        content_hash: Optional[str] = None,
        package_name: Optional[str] = None,
        package_version: Optional[str] = None,
        con: Optional[duckdb.DuckDBPyConnection] = None,
    ) -> str:
        """
        Ingest an in-memory RDF document into the store under `g=graph_id`.

        BNodes are *namespaced per graph* using a stable token derived from `graph_id`
        so that persisted/exported blank node ids remain stable across exports and do
        not collide across graphs.
        """
        normalized_document = _normalize_ingest_document(document)
        context = _normalize_context(normalized_document.get("@context"), base_path=base_path)
        base_context = _load_packaged_cf_context()
        for key, value in base_context.items():
            if key in context and context[key] != value:
                logger.warning(
                    "RDF document context conflict for '%s': %s != %s", key, context[key], value
                )
                continue
            context.setdefault(key, value)

        # Avoid RDFlib fetching external contexts by materializing a merged dict context.
        payload = dict(normalized_document)
        payload["@context"] = context

        graph_kind = graph_kind or _default_graph_kind(graph_id)
        if content_hash is None:
            content_hash = _sha256_hex(_canonical_json(payload))

        graph = Graph()
        graph.parse(data=json.dumps(payload), format=_STRUCTURED_FORMAT)

        graph_token = hashlib.sha1(graph_id.encode("utf-8")).hexdigest()[:12]
        rows: List[Tuple[Any, ...]] = []
        for subj, pred, obj in graph:
            if isinstance(subj, BNode):
                subj_kind = _KIND_BNODE
                subj_value = f"{graph_token}_{subj}"
            else:
                subj_kind = _KIND_IRI
                subj_value = str(subj)

            pred_value = str(pred)

            if isinstance(obj, Literal):
                obj_kind = _KIND_LITERAL
                obj_value = str(obj)
                obj_datatype = str(obj.datatype) if obj.datatype else None
                obj_lang = obj.language
                if (
                    pred_value in _TYPED_VALUE_PREDICATES
                    and obj_datatype is None
                    and obj_lang is None
                ):
                    obj_datatype = _XSD_STRING
            elif isinstance(obj, BNode):
                obj_kind = _KIND_BNODE
                obj_value = f"{graph_token}_{obj}"
                obj_datatype = None
                obj_lang = None
            else:
                obj_kind = _KIND_IRI
                obj_value = str(obj)
                obj_datatype = None
                obj_lang = None

            rows.append(
                (
                    graph_id,
                    subj_value,
                    pred_value,
                    obj_value,
                    obj_kind,
                    obj_datatype,
                    obj_lang,
                    subj_kind,
                    _KIND_IRI,
                    graph_type,
                    package,
                    source_path,
                )
            )

        context_payload = json.dumps(context, ensure_ascii=False)
        canonical_id, compact_id, local_id, aliases = _graph_alias_metadata(graph_id, context)
        aliases_payload = json.dumps(aliases, ensure_ascii=False)

        managed_con = con is None
        con = con or self._connect(read_only=False)
        try:
            if managed_con:
                con.execute("BEGIN TRANSACTION")
            con.execute("DELETE FROM rdf_quads WHERE g = ?", [graph_id])
            con.execute(
                """
                INSERT INTO graphs (
                  graph_id,
                  graph_type,
                  package,
                  path,
                  context,
                  graph_kind,
                  is_active,
                  content_hash,
                  package_name,
                  package_version,
                  canonical_id,
                  compact_id,
                  local_id,
                  aliases_json,
                  created_at,
                  updated_at
                )
                VALUES (
                  ?, ?, ?, ?, CAST(? AS JSON),
                  ?, ?, ?,
                  ?, ?,
                  ?, ?, ?, CAST(? AS JSON),
                  current_timestamp,
                  current_timestamp
                )
                ON CONFLICT (graph_id)
                DO UPDATE SET
                  graph_type = EXCLUDED.graph_type,
                  package = EXCLUDED.package,
                  path = EXCLUDED.path,
                  context = EXCLUDED.context,
                  graph_kind = EXCLUDED.graph_kind,
                  is_active = EXCLUDED.is_active,
                  content_hash = EXCLUDED.content_hash,
                  package_name = EXCLUDED.package_name,
                  package_version = EXCLUDED.package_version,
                  canonical_id = EXCLUDED.canonical_id,
                  compact_id = EXCLUDED.compact_id,
                  local_id = EXCLUDED.local_id,
                  aliases_json = EXCLUDED.aliases_json,
                  updated_at = EXCLUDED.updated_at
                """,
                [
                    graph_id,
                    graph_type,
                    package,
                    source_path,
                    context_payload,
                    graph_kind,
                    bool(is_active),
                    content_hash,
                    package_name,
                    package_version,
                    canonical_id,
                    compact_id,
                    local_id,
                    aliases_payload,
                ],
            )

            if rows:
                con.executemany(
                    """
                    INSERT INTO rdf_quads (
                      g, s, p, o,
                      o_kind, o_datatype, o_lang,
                      s_kind, p_kind,
                      graph_type, package, path,
                      updated_at
                    )
                    VALUES (
                      ?, ?, ?, ?,
                      ?, ?, ?,
                      ?, ?,
                      ?, ?, ?,
                      current_timestamp
                    )
                    """,
                    rows,
                )
            if managed_con:
                con.execute("COMMIT")
        except Exception:
            if managed_con:
                con.execute("ROLLBACK")
            raise
        finally:
            if managed_con:
                con.close()

        return content_hash

    # ------------------------------------------------------------------ #
    # MARK: Query / Export
    # ------------------------------------------------------------------ #

    def list_graphs(self) -> List[GraphCatalogEntry]:
        """List catalog entries for all stored graphs."""
        con = self._connect(read_only=True)
        try:
            rows = con.execute(
                """
                SELECT
                  graph_id,
                  graph_type,
                  package,
                  path,
                  context,
                  graph_kind,
                  is_active,
                  content_hash,
                  package_name,
                  package_version,
                  canonical_id,
                  compact_id,
                  local_id,
                  aliases_json
                FROM graphs
                """
            ).fetchall()
        finally:
            con.close()

        entries: List[GraphCatalogEntry] = []
        for (
            graph_id,
            graph_type,
            package,
            path,
            context_value,
            graph_kind,
            is_active,
            content_hash,
            package_name,
            package_version,
            canonical_id,
            compact_id,
            local_id,
            aliases_json,
        ) in rows:
            context = _coerce_json(context_value) or {}
            aliases: List[str] = []
            if isinstance(aliases_json, list):
                aliases = [str(item) for item in aliases_json if isinstance(item, str)]
            elif isinstance(aliases_json, str):
                try:
                    parsed = json.loads(aliases_json)
                    if isinstance(parsed, list):
                        aliases = [str(item) for item in parsed if isinstance(item, str)]
                except Exception:  # pylint: disable=broad-exception-caught
                    aliases = []
            elif aliases_json is not None:
                try:
                    parsed = json.loads(str(aliases_json))
                    if isinstance(parsed, list):
                        aliases = [str(item) for item in parsed if isinstance(item, str)]
                except Exception:  # pylint: disable=broad-exception-caught
                    aliases = []
            entries.append(
                GraphCatalogEntry(
                    graph_id=str(graph_id),
                    graph_type=str(graph_type or ""),
                    package=str(package or ""),
                    path=str(path) if path is not None else None,
                    context=context,
                    graph_kind=str(graph_kind or ""),
                    is_active=bool(is_active) if is_active is not None else True,
                    content_hash=str(content_hash) if content_hash is not None else None,
                    package_name=str(package_name) if package_name is not None else None,
                    package_version=str(package_version) if package_version is not None else None,
                    canonical_id=str(canonical_id) if canonical_id is not None else None,
                    compact_id=str(compact_id) if compact_id is not None else None,
                    local_id=str(local_id) if local_id is not None else None,
                    aliases=aliases,
                )
            )
        return entries

    def list_graph_ids(
        self,
        *,
        graph_type: Optional[str] = None,
        exclude_graph_type: Optional[str] = None,
        graph_kind: Optional[str] = None,
        graph_kinds: Optional[List[str]] = None,
        include_inactive: bool = False,
        package_name: Optional[str] = None,
        package_version: Optional[str] = None,
    ) -> List[str]:
        """Return graph ids filtered by optional catalog metadata."""
        con = self._connect(read_only=True)
        try:
            where: List[str] = []
            params: List[Any] = []
            if graph_type is not None:
                where.append("graph_type = ?")
                params.append(graph_type)
            if exclude_graph_type is not None:
                where.append("graph_type != ?")
                params.append(exclude_graph_type)
            if graph_kind is not None:
                where.append("graph_kind = ?")
                params.append(graph_kind)
            if graph_kinds:
                where.append(f"graph_kind IN ({_sql_placeholders(graph_kinds)})")
                params.extend(graph_kinds)
            if not include_inactive:
                where.append("is_active = TRUE")
            if package_name is not None:
                where.append("package_name = ?")
                params.append(package_name)
            if package_version is not None:
                where.append("package_version = ?")
                params.append(package_version)

            clause = f"WHERE {' AND '.join(where)}" if where else ""
            rows = con.execute(
                f"SELECT graph_id FROM graphs {clause} ORDER BY graph_id", params
            ).fetchall()
        finally:
            con.close()
        return [str(r[0]) for r in rows]

    def export_document(
        self,
        graph_ids: Optional[List[str]] = None,
        *,
        flatten: bool = True,
    ) -> Dict[str, Any]:
        """
        Export selected graphs as RDF documents.

        - `flatten=True`: Flattened export with named graphs merged into one graph.
        - `flatten=False`: Dataset export (named graphs preserved).
        """
        selected_graph_ids = graph_ids
        if selected_graph_ids is None:
            selected_graph_ids = self.list_graph_ids()

        selected_set = set(selected_graph_ids)

        con = self._connect(read_only=True)
        try:
            if selected_graph_ids:
                placeholders = _sql_placeholders(selected_graph_ids)
                if flatten:
                    rows = con.execute(
                        f"""
                        SELECT s, p, o, o_kind, o_datatype, o_lang, s_kind
                        FROM rdf_quads
                        WHERE g IN ({placeholders})
                        """,
                        selected_graph_ids,
                    ).fetchall()
                else:
                    rows = con.execute(
                        f"""
                        SELECT g, s, p, o, o_kind, o_datatype, o_lang, s_kind
                        FROM rdf_quads
                        WHERE g IN ({placeholders})
                        """,
                        selected_graph_ids,
                    ).fetchall()
            else:
                rows = []
        finally:
            con.close()

        if flatten:
            sink: Union[Graph, Dataset] = Graph()
            for s, p, o, o_kind, o_datatype, o_lang, s_kind in rows:
                subj = BNode(str(s)) if str(s_kind) == _KIND_BNODE else URIRef(str(s))
                pred = URIRef(str(p))
                if str(o_kind) == _KIND_LITERAL:
                    dt = URIRef(str(o_datatype)) if o_datatype else None
                    obj = Literal(
                        str(o),
                        lang=str(o_lang) if o_lang else None,
                        datatype=dt,
                    )
                elif str(o_kind) == _KIND_BNODE:
                    obj = BNode(str(o))
                else:
                    obj = URIRef(str(o))
                sink.add((subj, pred, obj))
        else:
            sink = Dataset()
            for g, s, p, o, o_kind, o_datatype, o_lang, s_kind in rows:
                g_id = str(g)
                graph = sink.graph(URIRef(g_id))
                subj = BNode(str(s)) if str(s_kind) == _KIND_BNODE else URIRef(str(s))
                pred = URIRef(str(p))
                if str(o_kind) == _KIND_LITERAL:
                    dt = URIRef(str(o_datatype)) if o_datatype else None
                    obj = Literal(
                        str(o),
                        lang=str(o_lang) if o_lang else None,
                        datatype=dt,
                    )
                elif str(o_kind) == _KIND_BNODE:
                    obj = BNode(str(o))
                else:
                    obj = URIRef(str(o))
                graph.add((subj, pred, obj))

        context: Dict[str, Any] = dict(_load_packaged_cf_context())
        for entry in self.list_graphs():
            if entry.graph_id not in selected_set:
                continue
            for key, value in entry.context.items():
                if key in context and context[key] != value:
                    logger.warning(
                        "RDF document context conflict for '%s': %s != %s", key, context[key], value
                    )
                    continue
                context[key] = value
        for key, value in canonical_context().items():
            context.setdefault(key, value)

        payload = sink.serialize(
            format=_STRUCTURED_FORMAT,
            context=context,
            auto_compact=True,
        )
        if isinstance(payload, bytes):
            payload = payload.decode("utf-8")
        return json.loads(payload)

    def export_nquads(self, graph_ids: Optional[List[str]] = None) -> str:
        """Export selected graphs as deterministic N-Quads text."""
        selected_graph_ids = graph_ids
        if selected_graph_ids is None:
            selected_graph_ids = self.list_graph_ids()
        if not selected_graph_ids:
            return ""

        con = self._connect(read_only=True)
        try:
            placeholders = _sql_placeholders(selected_graph_ids)
            rows = con.execute(
                f"""
                SELECT g, s, p, o, o_kind, o_datatype, o_lang, s_kind
                FROM rdf_quads
                WHERE g IN ({placeholders})
                ORDER BY g, s, p, o, o_kind, o_datatype, o_lang
                """,
                selected_graph_ids,
            ).fetchall()
        finally:
            con.close()

        sink = Dataset()
        for g, s, p, o, o_kind, o_datatype, o_lang, s_kind in rows:
            graph = sink.graph(_graph_id_to_nquads_iri(str(g)))
            subj = BNode(str(s)) if str(s_kind) == _KIND_BNODE else URIRef(str(s))
            pred = URIRef(str(p))
            if str(o_kind) == _KIND_LITERAL:
                dt = URIRef(str(o_datatype)) if o_datatype else None
                obj = Literal(
                    str(o),
                    lang=str(o_lang) if o_lang else None,
                    datatype=dt,
                )
            elif str(o_kind) == _KIND_BNODE:
                obj = BNode(str(o))
            else:
                obj = URIRef(str(o))
            graph.add((subj, pred, obj))

        payload = sink.serialize(format="nquads")
        if isinstance(payload, bytes):
            payload = payload.decode("utf-8")
        return payload

    def query_quads(
        self,
        *,
        graph_id: Optional[str] = None,
        subject: Optional[str] = None,
        predicate: Optional[str] = None,
        obj: Optional[str] = None,
        limit: Optional[int] = None,
    ) -> List[Dict[str, Any]]:
        """
        Query raw quads with optional filters.

        This method is read-only and intended for diagnostics/inspection tooling.
        """
        where: List[str] = []
        params: List[Any] = []
        if graph_id:
            where.append("g = ?")
            params.append(str(graph_id))
        if subject:
            where.append("s = ?")
            params.append(str(subject))
        if predicate:
            where.append("p = ?")
            params.append(str(predicate))
        if obj:
            where.append("o = ?")
            params.append(str(obj))
        clause = f"WHERE {' AND '.join(where)}" if where else ""
        limit_sql = ""
        if isinstance(limit, int) and limit > 0:
            limit_sql = " LIMIT ?"
            params.append(int(limit))

        con = self._connect(read_only=True)
        try:
            rows = con.execute(
                f"""
                SELECT
                  g,
                  s,
                  p,
                  o,
                  o_kind,
                  o_datatype,
                  o_lang,
                  s_kind,
                  p_kind,
                  graph_type,
                  package,
                  path
                FROM rdf_quads
                {clause}
                ORDER BY g, s, p, o
                {limit_sql}
                """,
                params,
            ).fetchall()
        finally:
            con.close()

        return [
            {
                "graph_id": str(g),
                "subject": str(s),
                "predicate": str(p),
                "object": str(o),
                "object_kind": str(o_kind) if o_kind is not None else None,
                "object_datatype": str(o_datatype) if o_datatype is not None else None,
                "object_lang": str(o_lang) if o_lang is not None else None,
                "subject_kind": str(s_kind) if s_kind is not None else None,
                "predicate_kind": str(p_kind) if p_kind is not None else None,
                "graph_type": str(graph_type) if graph_type is not None else None,
                "package": str(package) if package is not None else None,
                "path": str(path) if path is not None else None,
            }
            for (
                g,
                s,
                p,
                o,
                o_kind,
                o_datatype,
                o_lang,
                s_kind,
                p_kind,
                graph_type,
                package,
                path,
            ) in rows
        ]

    # ------------------------------------------------------------------ #
    # MARK: Pipeline versioning (append-only)
    # ------------------------------------------------------------------ #

    def create_pipeline(self, pipeline_id: str, created_by: Optional[str] = None) -> None:
        """
        Ensure a pipeline exists in the catalog.

        Pipelines start with `current_rev=0` (no revisions committed yet).
        """
        con = self._connect(read_only=False)
        try:
            con.execute(
                """
                INSERT INTO pipelines (
                  pipeline_id,
                  current_rev,
                  created_at,
                  updated_at,
                  created_by,
                  updated_by
                )
                VALUES (?, 0, current_timestamp, current_timestamp, ?, ?)
                ON CONFLICT (pipeline_id) DO NOTHING
                """,
                [pipeline_id, created_by, created_by],
            )
        finally:
            con.close()

    def commit_pipeline_revision(
        self,
        pipeline_id: str,
        document: DocumentInput,
        *,
        created_by: Optional[str] = None,
        message: Optional[str] = None,
    ) -> int:
        """
        Commit a new pipeline revision (append-only) from an RDF document.

        Stores the revision as quads in graph `pipe:{pipeline_id}@{rev}` and advances
        `pipelines.current_rev` to that revision.
        """

        document, base_path, canonical, source_path = load_rdf_document_input(
            document,
            label="pipeline document",
        )
        content_hash = _sha256_hex(canonical)

        con = self._connect(read_only=False)
        try:
            con.execute("BEGIN TRANSACTION")
            row = con.execute(
                "SELECT current_rev FROM pipelines WHERE pipeline_id = ?",
                [pipeline_id],
            ).fetchone()
            if row is None:
                current_rev = 0
                con.execute(
                    """
                    INSERT INTO pipelines (
                      pipeline_id,
                      current_rev,
                      created_at,
                      updated_at,
                      created_by,
                      updated_by
                    )
                    VALUES (?, 0, current_timestamp, current_timestamp, ?, ?)
                    """,
                    [pipeline_id, created_by, created_by],
                )
            else:
                current_rev = int(row[0] or 0)

            parent_rev = current_rev if current_rev > 0 else None
            rev = current_rev + 1
            graph_id = f"pipe:{pipeline_id}@{rev}"

            self.ingest_document(
                document,
                graph_id=graph_id,
                graph_type="pipeline",
                package="pipelines",
                source_path=source_path,
                base_path=base_path,
                graph_kind="pipeline_rev",
                is_active=True,
                content_hash=content_hash,
                con=con,
            )

            con.execute(
                """
                INSERT INTO pipeline_revisions (
                  pipeline_id,
                  rev,
                  graph_id,
                  created_at,
                  created_by,
                  message,
                  content_hash,
                  parent_rev
                )
                VALUES (?, ?, ?, current_timestamp, ?, ?, ?, ?)
                """,
                [pipeline_id, rev, graph_id, created_by, message, content_hash, parent_rev],
            )
            con.execute(
                """
                UPDATE pipelines
                SET current_rev = ?,
                    updated_at = current_timestamp,
                    updated_by = ?
                WHERE pipeline_id = ?
                """,
                [rev, created_by, pipeline_id],
            )
            con.execute("COMMIT")
        except Exception:
            con.execute("ROLLBACK")
            raise
        finally:
            con.close()

        return rev

    def get_pipeline_current_graph_id(self, pipeline_id: str) -> str:
        """Return the graph id for the current revision of a pipeline."""
        con = self._connect(read_only=True)
        try:
            row = con.execute(
                """
                SELECT pr.graph_id
                FROM pipelines p
                JOIN pipeline_revisions pr
                  ON pr.pipeline_id = p.pipeline_id
                 AND pr.rev = p.current_rev
                WHERE p.pipeline_id = ?
                """,
                [pipeline_id],
            ).fetchone()
        finally:
            con.close()
        if not row:
            raise KeyError(f"Pipeline not found or has no revisions: {pipeline_id}")
        return str(row[0])

    def list_current_pipeline_graph_ids(self) -> List[str]:
        """Return graph ids for each pipeline's current revision."""
        con = self._connect(read_only=True)
        try:
            rows = con.execute(
                """
                SELECT pr.graph_id
                FROM pipelines p
                JOIN pipeline_revisions pr
                  ON pr.pipeline_id = p.pipeline_id
                 AND pr.rev = p.current_rev
                ORDER BY pr.graph_id
                """
            ).fetchall()
        finally:
            con.close()
        return [str(r[0]) for r in rows]

    def list_pipeline_revisions(self, pipeline_id: str) -> List[Dict[str, Any]]:
        """Return ordered revision metadata for a pipeline id."""
        con = self._connect(read_only=True)
        try:
            rows = con.execute(
                """
                SELECT
                  rev,
                  graph_id,
                  created_at,
                  created_by,
                  message,
                  content_hash,
                  parent_rev
                FROM pipeline_revisions
                WHERE pipeline_id = ?
                ORDER BY rev
                """,
                [pipeline_id],
            ).fetchall()
        finally:
            con.close()

        return [
            {
                "pipeline_id": pipeline_id,
                "rev": int(rev),
                "graph_id": str(graph_id),
                "created_at": created_at,
                "created_by": created_by,
                "message": message,
                "content_hash": content_hash,
                "parent_rev": int(parent_rev) if parent_rev is not None else None,
            }
            for rev, graph_id, created_at, created_by, message, content_hash, parent_rev in rows
        ]

    def load_pipeline_revision_document(
        self,
        pipeline_id: str,
        rev: int,
        *,
        flatten: bool = True,
    ) -> Dict[str, Any]:
        """Export one pipeline revision graph as an RDF document."""
        con = self._connect(read_only=True)
        try:
            row = con.execute(
                "SELECT graph_id FROM pipeline_revisions WHERE pipeline_id = ? AND rev = ?",
                [pipeline_id, int(rev)],
            ).fetchone()
        finally:
            con.close()
        if not row:
            raise KeyError(f"Pipeline revision not found: {pipeline_id}@{rev}")
        return self.export_document([str(row[0])], flatten=flatten)

    def load_pipeline_revision_nquads(self, pipeline_id: str, rev: int) -> str:
        """Export one pipeline revision graph as N-Quads."""
        con = self._connect(read_only=True)
        try:
            row = con.execute(
                "SELECT graph_id FROM pipeline_revisions WHERE pipeline_id = ? AND rev = ?",
                [pipeline_id, int(rev)],
            ).fetchone()
        finally:
            con.close()
        if not row:
            raise KeyError(f"Pipeline revision not found: {pipeline_id}@{rev}")
        return self.export_nquads([str(row[0])])
