"""Local persistence for cloud skill bindings and package metadata."""

from __future__ import annotations

import hashlib
import json
import re
import sqlite3
import threading
import unicodedata
import uuid
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Generator, Iterable, Mapping

from openspace.config.constants import get_default_db_path

SKILL_ID_FILENAME = ".skill_id"
CLOUD_SKILL_SIDECAR_FILENAME = ".cloud_skill.json"
CLOUD_SKILL_INFO_FILENAME = CLOUD_SKILL_SIDECAR_FILENAME
UPLOAD_META_FILENAME = ".upload_meta.json"

_HASH_EXCLUDED_FILENAMES = frozenset({
    SKILL_ID_FILENAME,
    CLOUD_SKILL_SIDECAR_FILENAME,
    UPLOAD_META_FILENAME,
})

_SYNC_STATES = frozenset({
    "clean",
    "dirty",
    "conflict",
    "uploaded",
    "metadata_only",
})
_CATEGORIES = frozenset({"workflow", "tool_guide", "reference"})
_REVIEW_STATES = frozenset({"auto", "needs_review", "reviewed", "conflict"})

_DDL = """
CREATE TABLE IF NOT EXISTS skill_cloud_bindings (
    local_skill_id TEXT PRIMARY KEY,
    cloud_skill_id TEXT UNIQUE,
    local_path TEXT NOT NULL DEFAULT '',
    package_id_at_pull TEXT,
    package_path_at_pull TEXT,
    package_snapshot_version_at_pull TEXT,
    current_package_id TEXT,
    current_package_path TEXT,
    source_cloud_skill_id TEXT,
    manifest_hash TEXT,
    local_content_hash TEXT,
    sync_state TEXT NOT NULL DEFAULT 'clean'
        CHECK(sync_state IN ('clean', 'dirty', 'conflict', 'uploaded', 'metadata_only')),
    last_pulled_at TEXT,
    last_pushed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_skill_cloud_bindings_cloud
    ON skill_cloud_bindings(cloud_skill_id);

CREATE TABLE IF NOT EXISTS skill_local_classifications (
    local_skill_id TEXT PRIMARY KEY,
    category TEXT NOT NULL
        CHECK(category IN ('workflow', 'tool_guide', 'reference')),
    local_category_path TEXT NOT NULL DEFAULT '',
    classification_confidence REAL,
    classification_rationale TEXT NOT NULL DEFAULT '',
    review_state TEXT NOT NULL DEFAULT 'auto'
        CHECK(review_state IN ('auto', 'needs_review', 'reviewed', 'conflict')),
    evidence_json TEXT NOT NULL DEFAULT '{}',
    updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS package_cache (
    package_id TEXT PRIMARY KEY,
    package_path TEXT NOT NULL DEFAULT '',
    projection_hash TEXT,
    serving_epoch TEXT,
    source_epoch TEXT,
    last_pulled_at TEXT
);

"""


class CloudLocalMappingError(RuntimeError):
    """Raised when local cloud mapping state cannot satisfy a request."""


class UnboundLocalSkillError(CloudLocalMappingError):
    """Raised when a local parent skill has no cloud binding."""

    code = "PARENT_CLOUD_BINDING_REQUIRED"

    def __init__(self, missing_parent_local_skill_ids: Iterable[str]):
        self.missing_parent_local_skill_ids = list(missing_parent_local_skill_ids)
        super().__init__(
            "v2 fix/derive upload requires every parent local skill to have a "
            "cloud_skill_id: "
            + ", ".join(self.missing_parent_local_skill_ids)
        )

    def to_payload(self) -> dict[str, Any]:
        return {
            "status": "error",
            "code": self.code,
            "message": str(self),
            "missing_parent_local_skill_ids": self.missing_parent_local_skill_ids,
            "policy_decision_required": True,
            "allowed_resolutions": [
                "upload_parent_first",
                "upload_as_imported_or_capture",
            ],
        }


@dataclass(frozen=True)
class SkillCloudBinding:
    local_skill_id: str
    cloud_skill_id: str | None = None
    local_path: str = ""
    package_id_at_pull: str | None = None
    package_path_at_pull: str | None = None
    package_snapshot_version_at_pull: str | None = None
    current_package_id: str | None = None
    current_package_path: str | None = None
    source_cloud_skill_id: str | None = None
    manifest_hash: str | None = None
    local_content_hash: str | None = None
    sync_state: str = "clean"
    last_pulled_at: str | None = None
    last_pushed_at: str | None = None


CloudSkillBinding = SkillCloudBinding


@dataclass(frozen=True)
class SkillLocalClassification:
    local_skill_id: str
    category: str
    local_category_path: str = ""
    classification_confidence: float | None = None
    classification_rationale: str = ""
    review_state: str = "auto"
    evidence: Any = None
    updated_at: str = ""


@dataclass(frozen=True)
class PackageCacheEntry:
    package_id: str
    package_path: str = ""
    projection_hash: str | None = None
    serving_epoch: str | None = None
    source_epoch: str | None = None
    last_pulled_at: str | None = None


@dataclass(frozen=True)
class PackagePathIndexEntry:
    snapshot_version: str
    package_path: str
    package_id: str
    normalized_package_path: str = ""
    root_sub_domain_package_id: str | None = None
    parent_package_id: str | None = None
    package_kind: str = ""
    can_select_as_upload_target: bool = False
    can_create_child_regular_package: bool | None = None
    select_disabled_reason: str | None = None
    fetched_from: str = ""
    fetched_at: str | None = None


class CloudLocalMappingStore:
    """SQLite-backed local mapping store for cloud package/skill metadata."""

    def __init__(self, db_path: str | Path | None = None) -> None:
        if db_path is None:
            db_path = get_default_db_path(create=True)
        self._db_path = Path(db_path)
        self._db_path.parent.mkdir(parents=True, exist_ok=True)
        self._mu = threading.Lock()
        self._closed = False
        self._conn = self._make_connection(read_only=False)
        self._init_db()

    @property
    def db_path(self) -> Path:
        return self._db_path

    def close(self) -> None:
        if self._closed:
            return
        self._closed = True
        try:
            self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
            self._conn.close()
        except Exception:
            pass

    def __enter__(self) -> "CloudLocalMappingStore":
        return self

    def __exit__(self, *exc: object) -> None:
        self.close()

    def _make_connection(self, *, read_only: bool) -> sqlite3.Connection:
        conn = sqlite3.connect(
            str(self._db_path),
            timeout=30.0,
            check_same_thread=False,
        )
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA busy_timeout=30000")
        conn.execute("PRAGMA synchronous=NORMAL")
        conn.execute("PRAGMA foreign_keys=ON")
        if read_only:
            conn.execute("PRAGMA query_only=ON")
        conn.row_factory = sqlite3.Row
        return conn

    @contextmanager
    def _reader(self) -> Generator[sqlite3.Connection, None, None]:
        self._ensure_open()
        conn = self._make_connection(read_only=True)
        try:
            yield conn
        finally:
            conn.close()

    def _init_db(self) -> None:
        with self._mu:
            self._conn.executescript(_DDL)
            self._ensure_package_path_index_v2_locked()
            self._conn.commit()

    def _ensure_open(self) -> None:
        if self._closed:
            raise RuntimeError("CloudLocalMappingStore is closed")

    def _ensure_package_path_index_v2_locked(self) -> None:
        columns = {
            row["name"]
            for row in self._conn.execute("PRAGMA table_info(package_path_index)").fetchall()
        }
        required = {
            "normalized_package_path",
            "root_sub_domain_package_id",
            "parent_package_id",
            "can_create_child_regular_package",
            "select_disabled_reason",
            "fetched_from",
            "fetched_at",
        }
        if required.issubset(columns):
            return
        self._conn.execute("DROP TABLE IF EXISTS package_path_index")
        self._conn.execute("DROP INDEX IF EXISTS idx_package_path_index_package_id")
        self._conn.execute("DROP INDEX IF EXISTS idx_package_path_index_selectable")
        self._conn.executescript(
            """
            CREATE TABLE package_path_index (
                snapshot_version TEXT NOT NULL,
                normalized_package_path TEXT NOT NULL,
                package_id TEXT NOT NULL,
                package_path TEXT NOT NULL,
                root_sub_domain_package_id TEXT,
                parent_package_id TEXT,
                package_kind TEXT NOT NULL DEFAULT '',
                can_select_as_upload_target INTEGER NOT NULL DEFAULT 0,
                can_create_child_regular_package INTEGER,
                select_disabled_reason TEXT,
                fetched_from TEXT NOT NULL DEFAULT '',
                fetched_at TEXT,
                PRIMARY KEY (snapshot_version, normalized_package_path, package_id)
            );
            CREATE INDEX IF NOT EXISTS idx_package_path_index_package_id_v2
                ON package_path_index(package_id);
            CREATE INDEX IF NOT EXISTS idx_package_path_index_selectable_v2
                ON package_path_index(snapshot_version, can_select_as_upload_target);
            CREATE INDEX IF NOT EXISTS idx_package_path_index_path_v2
                ON package_path_index(snapshot_version, normalized_package_path);
            """
        )

    def upsert_skill_cloud_binding(
        self,
        *,
        local_skill_id: str,
        cloud_skill_id: str | None = None,
        local_path: str = "",
        package_id_at_pull: str | None = None,
        package_path_at_pull: str | None = None,
        package_snapshot_version_at_pull: str | None = None,
        current_package_id: str | None = None,
        current_package_path: str | None = None,
        source_cloud_skill_id: str | None = None,
        manifest_hash: str | None = None,
        local_content_hash: str | None = None,
        sync_state: str = "clean",
        last_pulled_at: str | None = None,
        last_pushed_at: str | None = None,
    ) -> SkillCloudBinding:
        """Insert or update one local skill to cloud skill binding."""

        self._ensure_open()
        local_skill_id = _required_text(local_skill_id, "local_skill_id")
        cloud_skill_id = _optional_text(cloud_skill_id)
        if sync_state not in _SYNC_STATES:
            raise ValueError(f"sync_state must be one of {sorted(_SYNC_STATES)}")
        with self._mu:
            self._conn.execute(
                """
                INSERT INTO skill_cloud_bindings (
                    local_skill_id,
                    cloud_skill_id,
                    local_path,
                    package_id_at_pull,
                    package_path_at_pull,
                    package_snapshot_version_at_pull,
                    current_package_id,
                    current_package_path,
                    source_cloud_skill_id,
                    manifest_hash,
                    local_content_hash,
                    sync_state,
                    last_pulled_at,
                    last_pushed_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(local_skill_id) DO UPDATE SET
                    cloud_skill_id=excluded.cloud_skill_id,
                    local_path=excluded.local_path,
                    package_id_at_pull=excluded.package_id_at_pull,
                    package_path_at_pull=excluded.package_path_at_pull,
                    package_snapshot_version_at_pull=excluded.package_snapshot_version_at_pull,
                    current_package_id=excluded.current_package_id,
                    current_package_path=excluded.current_package_path,
                    source_cloud_skill_id=excluded.source_cloud_skill_id,
                    manifest_hash=excluded.manifest_hash,
                    local_content_hash=excluded.local_content_hash,
                    sync_state=excluded.sync_state,
                    last_pulled_at=excluded.last_pulled_at,
                    last_pushed_at=excluded.last_pushed_at
                """,
                (
                    local_skill_id,
                    cloud_skill_id,
                    str(local_path or ""),
                    _optional_text(package_id_at_pull),
                    _optional_text(package_path_at_pull),
                    _optional_text(package_snapshot_version_at_pull),
                    _optional_text(current_package_id),
                    _optional_text(current_package_path),
                    _optional_text(source_cloud_skill_id),
                    _optional_text(manifest_hash),
                    _optional_text(local_content_hash),
                    sync_state,
                    _optional_text(last_pulled_at),
                    _optional_text(last_pushed_at),
                ),
            )
            self._conn.commit()
        binding = self.get_skill_cloud_binding_by_local(local_skill_id)
        if binding is None:
            raise CloudLocalMappingError(
                f"Failed to load saved binding for local skill {local_skill_id!r}"
            )
        return binding

    def upsert_binding(self, binding: SkillCloudBinding) -> SkillCloudBinding:
        return self.upsert_skill_cloud_binding(
            local_skill_id=binding.local_skill_id,
            cloud_skill_id=binding.cloud_skill_id,
            local_path=binding.local_path,
            package_id_at_pull=binding.package_id_at_pull,
            package_path_at_pull=binding.package_path_at_pull,
            package_snapshot_version_at_pull=binding.package_snapshot_version_at_pull,
            current_package_id=binding.current_package_id,
            current_package_path=binding.current_package_path,
            source_cloud_skill_id=binding.source_cloud_skill_id,
            manifest_hash=binding.manifest_hash,
            local_content_hash=binding.local_content_hash,
            sync_state=binding.sync_state,
            last_pulled_at=binding.last_pulled_at,
            last_pushed_at=binding.last_pushed_at,
        )

    def get_skill_cloud_binding_by_local(
        self,
        local_skill_id: str,
    ) -> SkillCloudBinding | None:
        """Return a cloud binding by local skill id."""

        with self._reader() as conn:
            row = conn.execute(
                "SELECT * FROM skill_cloud_bindings WHERE local_skill_id=?",
                (local_skill_id,),
            ).fetchone()
        return _row_to_skill_cloud_binding(row) if row else None

    def get_binding_by_local(self, local_skill_id: str) -> SkillCloudBinding | None:
        return self.get_skill_cloud_binding_by_local(local_skill_id)

    def get_skill_cloud_binding_by_cloud(
        self,
        cloud_skill_id: str,
    ) -> SkillCloudBinding | None:
        """Return the current local binding for a cloud skill id."""

        cloud_skill_id = _required_text(cloud_skill_id, "cloud_skill_id")
        with self._reader() as conn:
            row = conn.execute(
                "SELECT * FROM skill_cloud_bindings WHERE cloud_skill_id=?",
                (cloud_skill_id,),
            ).fetchone()
        return _row_to_skill_cloud_binding(row) if row else None

    def get_binding_by_cloud(self, cloud_skill_id: str) -> SkillCloudBinding | None:
        return self.get_skill_cloud_binding_by_cloud(cloud_skill_id)

    def resolve_parent_local_ids_to_cloud_ids(
        self,
        parent_local_skill_ids: Iterable[str],
    ) -> list[str]:
        """Resolve local parent skill ids to cloud ids for fix/derive upload."""

        cloud_ids: list[str] = []
        missing: list[str] = []
        for local_skill_id in parent_local_skill_ids:
            local_id = _required_text(local_skill_id, "parent_local_skill_id")
            binding = self.get_skill_cloud_binding_by_local(local_id)
            if binding is None or not binding.cloud_skill_id:
                missing.append(local_id)
                continue
            cloud_ids.append(binding.cloud_skill_id)
        if missing:
            raise UnboundLocalSkillError(missing)
        return cloud_ids

    def resolve_parent_cloud_skill_ids(
        self,
        parent_local_skill_ids: Iterable[str],
    ) -> list[str]:
        return self.resolve_parent_local_ids_to_cloud_ids(parent_local_skill_ids)

    def upsert_skill_local_classification(
        self,
        *,
        local_skill_id: str,
        category: str,
        updated_at: str,
        local_category_path: str = "",
        classification_confidence: float | None = None,
        classification_rationale: str = "",
        review_state: str = "auto",
        evidence: Any = None,
    ) -> SkillLocalClassification:
        """Insert or update local package/category classification metadata."""

        self._ensure_open()
        local_skill_id = _required_text(local_skill_id, "local_skill_id")
        if category not in _CATEGORIES:
            raise ValueError(f"category must be one of {sorted(_CATEGORIES)}")
        if review_state not in _REVIEW_STATES:
            raise ValueError(f"review_state must be one of {sorted(_REVIEW_STATES)}")
        updated_at = _required_text(updated_at, "updated_at")
        evidence_json = _dump_json(evidence if evidence is not None else {})
        with self._mu:
            self._conn.execute(
                """
                INSERT INTO skill_local_classifications (
                    local_skill_id,
                    category,
                    local_category_path,
                    classification_confidence,
                    classification_rationale,
                    review_state,
                    evidence_json,
                    updated_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(local_skill_id) DO UPDATE SET
                    category=excluded.category,
                    local_category_path=excluded.local_category_path,
                    classification_confidence=excluded.classification_confidence,
                    classification_rationale=excluded.classification_rationale,
                    review_state=excluded.review_state,
                    evidence_json=excluded.evidence_json,
                    updated_at=excluded.updated_at
                """,
                (
                    local_skill_id,
                    category,
                    str(local_category_path or ""),
                    classification_confidence,
                    str(classification_rationale or ""),
                    review_state,
                    evidence_json,
                    updated_at,
                ),
            )
            self._conn.commit()
        classification = self.get_skill_local_classification(local_skill_id)
        if classification is None:
            raise CloudLocalMappingError(
                f"Failed to load saved classification for {local_skill_id!r}"
            )
        return classification

    def get_skill_local_classification(
        self,
        local_skill_id: str,
    ) -> SkillLocalClassification | None:
        with self._reader() as conn:
            row = conn.execute(
                "SELECT * FROM skill_local_classifications WHERE local_skill_id=?",
                (local_skill_id,),
            ).fetchone()
        return _row_to_skill_local_classification(row) if row else None

    def list_skill_local_classifications(
        self,
        *,
        category: str | None = None,
        limit: int | None = None,
    ) -> list[SkillLocalClassification]:
        clauses: list[str] = []
        params: list[Any] = []
        if category:
            if category not in _CATEGORIES:
                raise ValueError(f"category must be one of {sorted(_CATEGORIES)}")
            clauses.append("category=?")
            params.append(category)
        limit_clause = ""
        if limit is not None:
            limit_clause = " LIMIT ?"
            params.append(max(int(limit), 1))
        where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
        with self._reader() as conn:
            rows = conn.execute(
                "SELECT * FROM skill_local_classifications"
                f"{where} ORDER BY category, local_category_path, local_skill_id"
                f"{limit_clause}",
                params,
            ).fetchall()
        return [_row_to_skill_local_classification(row) for row in rows]

    def upsert_package_cache(
        self,
        *,
        package_id: str,
        package_path: str = "",
        projection_hash: str | None = None,
        serving_epoch: str | None = None,
        source_epoch: str | None = None,
        last_pulled_at: str | None = None,
    ) -> PackageCacheEntry:
        """Insert or update one pulled package cache record."""

        self._ensure_open()
        package_id = _required_text(package_id, "package_id")
        with self._mu:
            self._conn.execute(
                """
                INSERT INTO package_cache (
                    package_id,
                    package_path,
                    projection_hash,
                    serving_epoch,
                    source_epoch,
                    last_pulled_at
                ) VALUES (?, ?, ?, ?, ?, ?)
                ON CONFLICT(package_id) DO UPDATE SET
                    package_path=excluded.package_path,
                    projection_hash=excluded.projection_hash,
                    serving_epoch=excluded.serving_epoch,
                    source_epoch=excluded.source_epoch,
                    last_pulled_at=excluded.last_pulled_at
                """,
                (
                    package_id,
                    str(package_path or ""),
                    _optional_text(projection_hash),
                    _optional_text(serving_epoch),
                    _optional_text(source_epoch),
                    _optional_text(last_pulled_at),
                ),
            )
            self._conn.commit()
        package = self.get_package_cache(package_id)
        if package is None:
            raise CloudLocalMappingError(
                f"Failed to load saved package cache for {package_id!r}"
            )
        return package

    def get_package_cache(self, package_id: str) -> PackageCacheEntry | None:
        with self._reader() as conn:
            row = conn.execute(
                "SELECT * FROM package_cache WHERE package_id=?",
                (package_id,),
            ).fetchone()
        return _row_to_package_cache_entry(row) if row else None

    def upsert_package_path_index_entry(
        self,
        *,
        snapshot_version: str,
        package_path: str,
        package_id: str,
        root_sub_domain_package_id: str | None = None,
        parent_package_id: str | None = None,
        package_kind: str = "",
        can_select_as_upload_target: bool = False,
        can_create_child_regular_package: bool | None = None,
        select_disabled_reason: str | None = None,
        fetched_from: str = "",
        fetched_at: str | None = None,
    ) -> PackagePathIndexEntry:
        """Insert or update one package path index row."""

        self._ensure_open()
        snapshot_version = _required_text(snapshot_version, "snapshot_version")
        package_path = _required_text(package_path, "package_path")
        package_id = _required_text(package_id, "package_id")
        normalized_package_path = normalize_package_path_key(package_path)
        with self._mu:
            self._conn.execute(
                """
                INSERT INTO package_path_index (
                    snapshot_version,
                    normalized_package_path,
                    package_id,
                    package_path,
                    root_sub_domain_package_id,
                    parent_package_id,
                    package_kind,
                    can_select_as_upload_target,
                    can_create_child_regular_package,
                    select_disabled_reason,
                    fetched_from,
                    fetched_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(snapshot_version, normalized_package_path, package_id) DO UPDATE SET
                    package_path=excluded.package_path,
                    root_sub_domain_package_id=excluded.root_sub_domain_package_id,
                    parent_package_id=excluded.parent_package_id,
                    package_kind=excluded.package_kind,
                    can_select_as_upload_target=excluded.can_select_as_upload_target,
                    can_create_child_regular_package=excluded.can_create_child_regular_package,
                    select_disabled_reason=excluded.select_disabled_reason,
                    fetched_from=excluded.fetched_from,
                    fetched_at=excluded.fetched_at
                """,
                (
                    snapshot_version,
                    normalized_package_path,
                    package_id,
                    package_path,
                    _optional_text(root_sub_domain_package_id),
                    _optional_text(parent_package_id),
                    str(package_kind or ""),
                    1 if can_select_as_upload_target else 0,
                    _optional_bool(can_create_child_regular_package),
                    _optional_text(select_disabled_reason),
                    str(fetched_from or ""),
                    _optional_text(fetched_at),
                ),
            )
            self._conn.commit()
        entry = self.get_package_path_index_entry(
            snapshot_version,
            package_path,
            package_id=package_id,
        )
        if entry is None:
            raise CloudLocalMappingError(
                f"Failed to load saved package path index for {package_path!r}"
            )
        return entry

    def get_package_path_index_entry(
        self,
        snapshot_version: str,
        package_path: str,
        *,
        package_id: str | None = None,
    ) -> PackagePathIndexEntry | None:
        entries = self.list_package_path_index_by_path(
            snapshot_version=snapshot_version,
            package_path=package_path,
            package_id=package_id,
        )
        if len(entries) > 1:
            raise CloudLocalMappingError(
                f"Package path {package_path!r} is ambiguous in snapshot {snapshot_version!r}"
            )
        return entries[0] if entries else None

    def list_package_path_index_by_path(
        self,
        *,
        snapshot_version: str,
        package_path: str,
        package_id: str | None = None,
    ) -> list[PackagePathIndexEntry]:
        snapshot_version = _required_text(snapshot_version, "snapshot_version")
        normalized_package_path = normalize_package_path_key(package_path)
        clauses = ["snapshot_version=?", "normalized_package_path=?"]
        params: list[Any] = [snapshot_version, normalized_package_path]
        if package_id is not None:
            clauses.append("package_id=?")
            params.append(_required_text(package_id, "package_id"))
        with self._reader() as conn:
            rows = conn.execute(
                "SELECT * FROM package_path_index "
                f"WHERE {' AND '.join(clauses)} "
                "ORDER BY package_path, package_id",
                params,
            ).fetchall()
        return [_row_to_package_path_index_entry(row) for row in rows]

    def list_package_path_index(
        self,
        *,
        snapshot_version: str | None = None,
        selectable_only: bool = False,
    ) -> list[PackagePathIndexEntry]:
        clauses: list[str] = []
        params: list[Any] = []
        if snapshot_version is not None:
            clauses.append("snapshot_version=?")
            params.append(snapshot_version)
        if selectable_only:
            clauses.append("can_select_as_upload_target=1")
        where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
        with self._reader() as conn:
            rows = conn.execute(
                "SELECT * FROM package_path_index"
                f"{where} ORDER BY snapshot_version, package_path",
                params,
            ).fetchall()
        return [_row_to_package_path_index_entry(row) for row in rows]


def read_cloud_skill_sidecar(skill_dir: str | Path) -> dict[str, Any] | None:
    """Read ``.cloud_skill.json`` from a skill directory if it exists."""

    sidecar_path = Path(skill_dir) / CLOUD_SKILL_SIDECAR_FILENAME
    if not sidecar_path.exists():
        return None
    try:
        payload = json.loads(sidecar_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise ValueError(f"Invalid cloud skill sidecar JSON: {sidecar_path}") from exc
    if not isinstance(payload, dict):
        raise ValueError(f"Cloud skill sidecar must contain a JSON object: {sidecar_path}")
    return payload


def write_cloud_skill_sidecar(
    skill_dir: str | Path,
    payload: Mapping[str, Any],
) -> Path:
    """Write ``.cloud_skill.json`` to a skill directory."""

    target_dir = Path(skill_dir)
    target_dir.mkdir(parents=True, exist_ok=True)
    sidecar_path = target_dir / CLOUD_SKILL_SIDECAR_FILENAME
    sidecar_path.write_text(_dump_json(dict(payload)) + "\n", encoding="utf-8")
    return sidecar_path


def write_cloud_skill_info(skill_dir: str | Path, binding: SkillCloudBinding) -> Path:
    return write_cloud_skill_sidecar(
        skill_dir,
        {
            "schema_version": 1,
            "api_version": "v2",
            "local_skill_id": binding.local_skill_id,
            "cloud_skill_id": binding.cloud_skill_id,
            "package_id_at_pull": binding.package_id_at_pull,
            "package_path_at_pull": binding.package_path_at_pull,
            "package_snapshot_version_at_pull": binding.package_snapshot_version_at_pull,
            "current_package_id": binding.current_package_id,
            "current_package_path": binding.current_package_path,
            "source_cloud_skill_id": binding.source_cloud_skill_id,
            "manifest_hash": binding.manifest_hash,
            "local_content_hash": binding.local_content_hash,
            "sync_state": binding.sync_state,
            "last_pulled_at": binding.last_pulled_at,
            "last_pushed_at": binding.last_pushed_at,
        },
    )


def compute_skill_local_content_hash(skill_dir: str | Path) -> str:
    """Compute a stable hash of local skill contents, excluding local sidecars."""

    root = Path(skill_dir)
    if not root.is_dir():
        raise ValueError(f"Skill directory not found: {root}")
    hasher = hashlib.sha256()
    for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()):
        if not path.is_file() or path.name in _HASH_EXCLUDED_FILENAMES:
            continue
        relative_path = path.relative_to(root).as_posix()
        data = path.read_bytes()
        hasher.update(relative_path.encode("utf-8"))
        hasher.update(b"\0")
        hasher.update(str(len(data)).encode("ascii"))
        hasher.update(b"\0")
        hasher.update(data)
        hasher.update(b"\0")
    return f"sha256:{hasher.hexdigest()}"


compute_local_content_hash = compute_skill_local_content_hash


def utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def read_local_skill_id(skill_dir: str | Path) -> str | None:
    try:
        value = (Path(skill_dir) / SKILL_ID_FILENAME).read_text(encoding="utf-8").strip()
    except OSError:
        return None
    return value or None


def write_local_skill_id(skill_dir: str | Path, local_skill_id: str) -> Path:
    local_id = _required_text(local_skill_id, "local_skill_id")
    path = Path(skill_dir) / SKILL_ID_FILENAME
    path.write_text(local_id + "\n", encoding="utf-8")
    return path


def generate_local_skill_id(skill_name: str) -> str:
    name = _required_text(skill_name or "skill", "skill_name")
    return f"{name}__imp_{uuid.uuid4().hex[:8]}"


def ensure_local_skill_id(skill_dir: str | Path, *, skill_name: str | None = None) -> str:
    existing = read_local_skill_id(skill_dir)
    if existing:
        return existing
    local_id = generate_local_skill_id(skill_name or Path(skill_dir).name)
    write_local_skill_id(skill_dir, local_id)
    return local_id


def _row_to_skill_cloud_binding(row: sqlite3.Row) -> SkillCloudBinding:
    return SkillCloudBinding(
        local_skill_id=row["local_skill_id"],
        cloud_skill_id=row["cloud_skill_id"],
        local_path=row["local_path"],
        package_id_at_pull=row["package_id_at_pull"],
        package_path_at_pull=row["package_path_at_pull"],
        package_snapshot_version_at_pull=row["package_snapshot_version_at_pull"],
        current_package_id=row["current_package_id"],
        current_package_path=row["current_package_path"],
        source_cloud_skill_id=row["source_cloud_skill_id"],
        manifest_hash=row["manifest_hash"],
        local_content_hash=row["local_content_hash"],
        sync_state=row["sync_state"],
        last_pulled_at=row["last_pulled_at"],
        last_pushed_at=row["last_pushed_at"],
    )


def _row_to_skill_local_classification(row: sqlite3.Row) -> SkillLocalClassification:
    return SkillLocalClassification(
        local_skill_id=row["local_skill_id"],
        category=row["category"],
        local_category_path=row["local_category_path"],
        classification_confidence=row["classification_confidence"],
        classification_rationale=row["classification_rationale"],
        review_state=row["review_state"],
        evidence=json.loads(row["evidence_json"]),
        updated_at=row["updated_at"],
    )


def _row_to_package_cache_entry(row: sqlite3.Row) -> PackageCacheEntry:
    return PackageCacheEntry(
        package_id=row["package_id"],
        package_path=row["package_path"],
        projection_hash=row["projection_hash"],
        serving_epoch=row["serving_epoch"],
        source_epoch=row["source_epoch"],
        last_pulled_at=row["last_pulled_at"],
    )


def _row_to_package_path_index_entry(row: sqlite3.Row) -> PackagePathIndexEntry:
    can_create = row["can_create_child_regular_package"]
    return PackagePathIndexEntry(
        snapshot_version=row["snapshot_version"],
        package_path=row["package_path"],
        package_id=row["package_id"],
        normalized_package_path=row["normalized_package_path"],
        root_sub_domain_package_id=row["root_sub_domain_package_id"],
        parent_package_id=row["parent_package_id"],
        package_kind=row["package_kind"],
        can_select_as_upload_target=bool(row["can_select_as_upload_target"]),
        can_create_child_regular_package=(
            None if can_create is None else bool(can_create)
        ),
        select_disabled_reason=row["select_disabled_reason"],
        fetched_from=row["fetched_from"],
        fetched_at=row["fetched_at"],
    )


def _required_text(value: str, field_name: str) -> str:
    text = str(value or "").strip()
    if not text:
        raise ValueError(f"{field_name} is required")
    return text


def _optional_text(value: Any) -> str | None:
    if value is None:
        return None
    text = str(value)
    return text if text else None


def _optional_bool(value: bool | None) -> int | None:
    if value is None:
        return None
    return 1 if value else 0


def normalize_package_path_key(value: str) -> str:
    text = unicodedata.normalize("NFKC", str(value or ""))
    segments = [
        re.sub(r"\s+", " ", segment.strip()).casefold()
        for segment in text.replace("\\", "/").split("/")
        if segment.strip()
    ]
    return "/".join(segments)


def _dump_json(value: Any) -> str:
    return json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2)
