"""Discovery and ingestion of dynamically installed CogniFlow step packages."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
import importlib
import importlib.metadata
from importlib import resources
import json
import logging
import duckdb

from .quad_store import (
    QuadStore,
    _canonical_json,
    _extract_step_ids,
    _extract_step_subgraph,
    _load_packaged_cf_context,
    _normalize_ingest_document,
    _normalize_context,
    _sha256_hex,
)
from .semantics_paths import resolve_semantics_db_path, resolve_semantics_dir

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class StepPackageDescriptor:
    """Descriptor for a step package discovered from entry points."""

    package_name: str
    package_version: str
    entry_point: str
    source_module: str
    jsonld_paths: Optional[List[str]] = None
    loader_callable: Optional[Callable[[], Any]] = None
    split_steps: bool = True


def _iter_step_entry_points():
    eps = importlib.metadata.entry_points()
    if hasattr(eps, "select"):
        return list(eps.select(group="cogniflow.steps"))  # type: ignore[attr-defined]
    if isinstance(eps, dict):
        return list(eps.get("cogniflow.steps", []))
    return []


def _extract_text(value: Any) -> Optional[str]:
    if isinstance(value, str):
        return value
    if isinstance(value, dict):
        if isinstance(value.get("@value"), str):
            return value.get("@value")
        if isinstance(value.get("@id"), str):
            return value.get("@id")
    if isinstance(value, list):
        for item in value:
            text = _extract_text(item)
            if text:
                return text
    return None


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_package_info(doc: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
    graph = doc.get("@graph")
    nodes = graph if isinstance(graph, list) else [doc]
    pkg_types = {
        "cf:StepPackage",
        "https://cogniflow.org/ns#StepPackage",
        "http://cogniflow.org/ns#StepPackage",
    }
    for node in nodes:
        if not isinstance(node, dict):
            continue
        if not _has_type(node, pkg_types):
            continue
        pkg_id = _extract_text(
            node.get("cf:packageId")
            or node.get("https://cogniflow.org/ns#packageId")
        )
        pkg_version = _extract_text(
            node.get("cf:packageVersion")
            or node.get("https://cogniflow.org/ns#packageVersion")
        )
        if pkg_id or pkg_version:
            return pkg_id, pkg_version
    return None, None


def discover_step_packages() -> List[StepPackageDescriptor]:
    """
    Discover installed step packages via the `cogniflow.steps` entry point group.

    Entry point value conventions:
    - `some_module:steps.jsonld` -> resource path relative to the package/module
    - `some_module:load_steps`   -> callable returning JSON-LD (dict or JSON string)

    Step packages are expected to expose `steps.jsonld` via the entry point.
    """
    descriptors: List[StepPackageDescriptor] = []
    for ep in _iter_step_entry_points():
        dist = getattr(ep, "dist", None)
        dist_name = getattr(dist, "name", None) or ep.name
        dist_version = getattr(dist, "version", None)
        if not dist_version:
            try:
                dist_version = importlib.metadata.version(dist_name)
            except importlib.metadata.PackageNotFoundError:
                dist_version = "0"

        module_name, _, ref = str(ep.value).partition(":")
        ref = ref.strip()
        if not module_name:
            continue

        package_root = module_name.rsplit(".", 1)[0] if "." in module_name else module_name
        source_module = package_root
        package_id = str(dist_name)
        package_version = str(dist_version)

        jsonld_paths: Optional[List[str]] = None
        loader_callable: Optional[Callable[[], Any]] = None

        if ref.endswith(".jsonld") or ref.endswith(".json"):
            jsonld_paths = [ref]
        else:
            try:
                module = importlib.import_module(module_name)
                if ref:
                    obj = getattr(module, ref)
                else:
                    obj = module
                if callable(obj):
                    loader_callable = obj  # type: ignore[assignment]
            except Exception as exc:  # pylint: disable=broad-exception-caught
                logger.warning("Failed to resolve step entry point %s: %s", ep, exc)
                continue

        descriptors.append(
            StepPackageDescriptor(
                package_name=package_id,
                package_version=package_version,
                entry_point=str(ep.name),
                source_module=str(source_module),
                jsonld_paths=jsonld_paths,
                loader_callable=loader_callable,
                split_steps=True,
            )
        )

    return descriptors


def _load_jsonld_from_descriptor(
    descriptor: StepPackageDescriptor,
) -> List[Tuple[Dict[str, Any], Optional[Path], str]]:
    """
    Returns a list of (document, base_path, source_path_label).
    """
    docs: List[Tuple[Dict[str, Any], Optional[Path], str]] = []
    if descriptor.jsonld_paths:
        for rel_path in descriptor.jsonld_paths:
            try:
                files = resources.files(descriptor.source_module)
            except (ModuleNotFoundError, FileNotFoundError, ImportError):
                # Fall back to package root.
                package_root = (
                    descriptor.source_module.rsplit(".", 1)[0]
                    if "." in descriptor.source_module
                    else descriptor.source_module
                )
                files = resources.files(package_root)

            candidate = files / rel_path
            if not candidate.is_file():
                logger.warning(
                    "Step package resource not found: %s:%s",
                    descriptor.source_module,
                    rel_path,
                )
                continue
            with resources.as_file(candidate) as tmp_path:
                doc = json.loads(Path(tmp_path).read_text(encoding="utf-8"))
                docs.append((doc, Path(tmp_path).resolve().parent, str(tmp_path)))
    elif descriptor.loader_callable:
        payload = descriptor.loader_callable()
        source_label = f"entrypoint:{descriptor.entry_point}"
        if isinstance(payload, Path):
            doc = json.loads(payload.read_text(encoding="utf-8"))
            docs.append((doc, payload.resolve().parent, str(payload)))
        elif isinstance(payload, dict):
            docs.append((payload, None, source_label))
        elif isinstance(payload, str):
            doc = json.loads(payload)
            docs.append((doc, None, source_label))
        else:
            raise TypeError(
                f"Unsupported loader result for {descriptor.entry_point}: {type(payload)!r}"
            )
    return docs


def _canonical_payload(document: Dict[str, Any], base_path: Optional[Path]) -> str:
    """
    Return a canonical JSON string with a normalized @context.

    This prevents unstable hashes caused by relative context paths that can vary
    across runs or installation layouts.
    """
    def _sort_key(value: Any) -> str:
        if isinstance(value, dict):
            if "@id" in value and isinstance(value.get("@id"), str):
                return f"@id:{value.get('@id')}"
            if "@value" in value:
                return f"@value:{value.get('@value')}"
            return str(value)
        if isinstance(value, (str, int, float, bool)) or value is None:
            return str(value)
        return str(value)

    def _normalize(value: Any) -> Any:
        if isinstance(value, dict):
            return {k: _normalize(v) for k, v in value.items()}
        if isinstance(value, list):
            normalized = [_normalize(v) for v in value]
            # Sort lists of node references by @id for deterministic order.
            if normalized and all(isinstance(v, dict) and "@id" in v for v in normalized):
                return sorted(normalized, key=lambda v: _sort_key(v.get("@id")))
            # Sort lists of primitives for deterministic order.
            if normalized and all(
                isinstance(v, (str, int, float, bool)) or v is None for v in normalized
            ):
                return sorted(normalized, key=_sort_key)
            return normalized
        return value

    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(
                "JSON-LD context conflict for '%s': %s != %s", key, context[key], value
            )
            continue
        context.setdefault(key, value)

    payload = dict(normalized_document)
    payload["@context"] = context
    payload = _normalize(payload)
    graph = payload.get("@graph")
    if isinstance(graph, list):
        payload["@graph"] = sorted(graph, key=_sort_key)
    return _canonical_json(payload)


def ingest_installed_step_packages(
    *,
    semantics_dir: Optional[Path] = None,
    force: bool = False,
) -> List[str]:
    """
    Discover and ingest installed step packages into the QuadStore.

    Graph IDs follow the binding conventions:
    - split steps: `pkg:{package_name}@{package_version}#{step_id}`
    - unsplit:     `pkg:{package_name}@{package_version}`

    Returns a list of graph ids that were inserted/updated.
    """
    base_dir = (semantics_dir or resolve_semantics_dir()).resolve()
    db_path = resolve_semantics_db_path(base_dir, kind="steps")
    store = QuadStore(db_path=db_path, semantics_dir=base_dir)
    store.init_schema(create_pipeline_tables=False)

    descriptors = discover_step_packages()
    installed_pairs: set[Tuple[str, str]] = set()

    changed: List[str] = []

    con = duckdb.connect(str(db_path), read_only=False)
    try:
        for descriptor in descriptors:
            for doc, base_path, source_path in _load_jsonld_from_descriptor(descriptor):
                pkg_id, pkg_version = _extract_package_info(doc)
                package_name = pkg_id or descriptor.package_name
                package_version = pkg_version or descriptor.package_version
                installed_pairs.add((str(package_name), str(package_version)))

                if descriptor.split_steps:
                    for step_id in _extract_step_ids(doc):
                        subgraph = _extract_step_subgraph(doc, step_id)
                        if not subgraph:
                            continue
                        graph_id = f"pkg:{package_name}@{package_version}#{step_id}"
                        canonical = _canonical_payload(subgraph, base_path)
                        content_hash = _sha256_hex(canonical)
                        existing = con.execute(
                            "SELECT content_hash FROM graphs WHERE graph_id = ?",
                            [graph_id],
                        ).fetchone()
                        if (
                            existing
                            and existing[0] is not None
                            and str(existing[0]) == content_hash
                            and not force
                        ):
                            continue
                        if (
                            existing
                            and existing[0] is not None
                            and str(existing[0]) != content_hash
                        ):
                            logger.warning(
                                (
                                    "Graph-id hash mismatch for %s "
                                    "(versioned ids should be stable); overwriting."
                                ),
                                graph_id,
                            )
                        store.ingest_jsonld_document(
                            subgraph,
                            graph_id=graph_id,
                            graph_type="step",
                            package=package_name,
                            source_path=source_path,
                            base_path=base_path,
                            graph_kind="package_step",
                            is_active=True,
                            content_hash=content_hash,
                            package_name=package_name,
                            package_version=package_version,
                            con=con,
                        )
                        changed.append(graph_id)
                else:
                    graph_id = f"pkg:{package_name}@{package_version}"
                    canonical = _canonical_payload(doc, base_path)
                    content_hash = _sha256_hex(canonical)
                    existing = con.execute(
                        "SELECT content_hash FROM graphs WHERE graph_id = ?",
                        [graph_id],
                    ).fetchone()
                    if (
                        existing
                        and existing[0] is not None
                        and str(existing[0]) == content_hash
                        and not force
                    ):
                        continue
                    store.ingest_jsonld_document(
                        doc,
                        graph_id=graph_id,
                        graph_type="step",
                        package=package_name,
                        source_path=source_path,
                        base_path=base_path,
                        graph_kind="package",
                        is_active=True,
                        content_hash=content_hash,
                        package_name=package_name,
                        package_version=package_version,
                        con=con,
                    )
                    changed.append(graph_id)

        # Mark missing packages inactive (do not delete).
        existing_pkg_rows = con.execute(
            """
            SELECT graph_id, package_name, package_version
            FROM graphs
            WHERE graph_kind IN ('package', 'package_step')
              AND is_active = TRUE
            """
        ).fetchall()
        for graph_id, pkg_name, pkg_version in existing_pkg_rows:
            pair = (
                str(pkg_name) if pkg_name is not None else "",
                str(pkg_version) if pkg_version is not None else "",
            )
            if pair not in installed_pairs:
                con.execute(
                    (
                        "UPDATE graphs SET is_active = FALSE, "
                        "updated_at = current_timestamp WHERE graph_id = ?"
                    ),
                    [str(graph_id)],
                )
    finally:
        con.close()

    return changed
