from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from urllib.parse import unquote, urlparse

from rdflib import BNode, Dataset, Graph, Literal, Namespace, RDF, URIRef
from rdflib.namespace import OWL

CF = Namespace("https://cogniflow.odea-project.org/cf#")
SCHEMA = Namespace("https://schema.org/")
CFRUN = Namespace("https://cogniflow.odea-project.org/cf#runner/")
_STRUCTURED_SUFFIX = "." + "json" + "ld"
_STRUCTURED_FORMAT = "json" + "-ld"


def _cf_terms(local_name: str) -> tuple[URIRef, ...]:
    return (URIRef(f"{CF}{local_name}"),)


def _cf_value(g: Graph, node: URIRef | BNode, local_name: str):
    for predicate in _cf_terms(local_name):
        value = g.value(node, predicate)
        if value is not None:
            return value
    return None


def _cf_objects(g: Graph, node: URIRef | BNode, local_name: str):
    seen = set()
    for predicate in _cf_terms(local_name):
        for value in g.objects(node, predicate):
            if value in seen:
                continue
            seen.add(value)
            yield value


def _iter_cf_type(g: Graph, local_name: str) -> Iterable[URIRef]:
    seen: set[URIRef] = set()
    for cf_type in _cf_terms(local_name):
        for subject in g.subjects(RDF.type, cf_type):
            if not isinstance(subject, URIRef) or subject in seen:
                continue
            seen.add(subject)
            yield subject


def _has_cf_type(g: Graph, node: URIRef | BNode, local_name: str) -> bool:
    return any((node, RDF.type, cf_type) in g for cf_type in _cf_terms(local_name))


@dataclass(frozen=True)
class ObservableDefinition:
    observable_uri: str
    observable_type_uri: str
    node_uri: str
    name: Optional[str] = None


@dataclass(frozen=True)
class ReportDefinition:
    report_uri: str
    pipeline_uri: str
    observables: List[ObservableDefinition]


@dataclass(frozen=True)
class PipelineNodeDefinition:
    node_uri: str
    position: int
    definition_uri: str
    parameters: Dict[str, Any]


@dataclass(frozen=True)
class EdgeDefinition:
    edge_uri: str
    from_node_uri: str
    from_port_key_uri: str
    to_node_uri: str
    to_port_key_uri: str
    to_label: Optional[str] = None


@dataclass(frozen=True)
class TriggerDefinition:
    trigger_uri: str
    event_type: Optional[str]


@dataclass(frozen=True)
class PipelineDefinition:
    pipeline_uri: str
    nodes: List[PipelineNodeDefinition]
    edges: List[EdgeDefinition]
    triggers: List[TriggerDefinition]


def _as_int(lit: Literal | None) -> int:
    if lit is None:
        raise ValueError("Missing required integer literal")
    try:
        return int(lit.toPython())
    except Exception as exc:
        raise ValueError(f"Invalid integer literal: {lit!r}") from exc


def _iter_reports(g: Graph) -> Iterable[URIRef]:
    yield from _iter_cf_type(g, "Report")


def _iter_pipelines(g: Graph) -> Iterable[URIRef]:
    yield from _iter_cf_type(g, "ProcessingPipeline")


def _iter_nodes_for_pipeline(g: Graph, pipeline: URIRef) -> Iterable[URIRef]:
    preds = (*_cf_terms("hasPipelineNode"), SCHEMA.itemListElement)
    seen: set[URIRef] = set()
    for pred in preds:
        for (_, _, node) in g.triples((pipeline, pred, None)):
            if isinstance(node, URIRef):
                if node in seen:
                    continue
                seen.add(node)
                yield node


def _local_name(iri: str) -> str:
    for sep in ("#", "/"):
        if sep in iri:
            return iri.rsplit(sep, 1)[-1]
    return iri


def _chart_field_from_graph(g: Graph, node: URIRef | BNode) -> Optional[Dict[str, Any]]:
    name = _cf_value(g, node, "fieldName")
    if isinstance(name, Literal):
        return {"field": str(name.toPython())}
    return None


def _chart_encoding_from_graph(g: Graph, node: URIRef | BNode) -> Optional[Dict[str, Any]]:
    x_node = _cf_value(g, node, "chartX")
    y_nodes = list(_cf_objects(g, node, "chartY"))
    color_node = _cf_value(g, node, "chartColor")
    if not any((x_node, y_nodes, color_node)):
        return None

    encoding: Dict[str, Any] = {}
    if isinstance(x_node, (URIRef, BNode)):
        x_field = _chart_field_from_graph(g, x_node)
        if x_field:
            encoding["x"] = x_field
    if y_nodes:
        y_fields = []
        for y_node in y_nodes:
            if not isinstance(y_node, (URIRef, BNode)):
                continue
            y_field = _chart_field_from_graph(g, y_node)
            if y_field:
                y_fields.append(y_field)
        if y_fields:
            encoding["y"] = y_fields if len(y_fields) > 1 else y_fields[0]
    if isinstance(color_node, (URIRef, BNode)):
        color_field = _chart_field_from_graph(g, color_node)
        if color_field:
            encoding["color"] = color_field
    return encoding or None


def _chart_spec_from_graph(g: Graph, node: URIRef | BNode) -> Optional[Dict[str, Any]]:
    if not _has_cf_type(g, node, "ChartSpec"):
        return None
    spec: Dict[str, Any] = {}
    mark = _cf_value(g, node, "chartMark")
    if isinstance(mark, URIRef):
        spec["mark"] = _local_name(str(mark))
    encoding_node = _cf_value(g, node, "chartEncoding")
    if isinstance(encoding_node, (URIRef, BNode)):
        encoding = _chart_encoding_from_graph(g, encoding_node)
        if encoding:
            spec["encoding"] = encoding
    return spec or None


def _parse_param_bindings(g: Graph, node: URIRef) -> Dict[str, Any]:
    params: Dict[str, Any] = {}
    for structured in _cf_objects(g, node, "parameterBinding"):
        if not isinstance(structured, (URIRef, BNode)):
            continue
        for (_, _, pv) in g.triples((structured, SCHEMA.additionalProperty, None)):
            if not isinstance(pv, (URIRef, BNode)):
                continue
            name_lit = g.value(pv, SCHEMA.propertyID) or g.value(pv, SCHEMA.name)
            if not isinstance(name_lit, Literal):
                continue
            key = str(name_lit.toPython())
            value = g.value(pv, SCHEMA.value)
            if isinstance(value, Literal):
                params[key] = value.toPython()
            elif isinstance(value, URIRef):
                spec = _chart_spec_from_graph(g, value)
                params[key] = spec if spec is not None else str(value)
            elif isinstance(value, BNode):
                spec = _chart_spec_from_graph(g, value)
                params[key] = spec if spec is not None else str(value)
            else:
                params[key] = None
    return params


def _parse_edges(g: Graph, pipeline: URIRef) -> List[EdgeDefinition]:
    edges: List[EdgeDefinition] = []
    for c in _cf_objects(g, pipeline, "hasConnection"):
        if not isinstance(c, (URIRef, BNode)):
            continue
        from_node = _cf_value(g, c, "sourceNode")
        to_node = _cf_value(g, c, "targetNode")
        if not all(isinstance(x, (URIRef, BNode)) for x in (from_node, to_node)):
            continue

        from_port = _cf_value(g, c, "sourcePortKey")
        to_port = _cf_value(g, c, "targetPortKey")
        if not all(isinstance(x, URIRef) for x in (from_port, to_port)):
            continue

        label_lit = _cf_value(g, c, "targetLabel")
        to_label = str(label_lit.toPython()) if isinstance(label_lit, Literal) else None
        edges.append(
            EdgeDefinition(
                edge_uri=str(c),
                from_node_uri=str(from_node),
                from_port_key_uri=str(from_port),
                to_node_uri=str(to_node),
                to_port_key_uri=str(to_port),
                to_label=to_label,
            )
        )
    return edges


def _parse_triggers(g: Graph, pipeline: URIRef) -> List[TriggerDefinition]:
    triggers: List[TriggerDefinition] = []
    seen: set[str] = set()
    for pred in (*_cf_terms("hasTrigger"), CFRUN.hasTrigger):
        for (_, _, t) in g.triples((pipeline, pred, None)):
            if not isinstance(t, URIRef):
                continue
            trigger_uri = str(t)
            if trigger_uri in seen:
                continue
            seen.add(trigger_uri)
            et = g.value(t, CFRUN.eventType) or g.value(t, SCHEMA.name)
            event_type = str(et.toPython()) if isinstance(et, Literal) else None
            triggers.append(
                TriggerDefinition(
                    trigger_uri=trigger_uri,
                    event_type=event_type,
                )
            )
    return triggers


def parse_pipeline(g: Graph, pipeline_uri: str) -> PipelineDefinition:
    pipeline = URIRef(pipeline_uri)
    nodes: List[PipelineNodeDefinition] = []
    for node in _iter_nodes_for_pipeline(g, pipeline):
        pos = _as_int(g.value(node, SCHEMA.position))
        definition = _cf_value(g, node, "nodeDefinition") or _cf_value(g, node, "stepDefinition")
        if not isinstance(definition, URIRef):
            continue
        params = _parse_param_bindings(g, node)
        nodes.append(
            PipelineNodeDefinition(
                node_uri=str(node),
                position=pos,
                definition_uri=str(definition),
                parameters=params,
            )
        )
    nodes = sorted(nodes, key=lambda x: x.position)
    return PipelineDefinition(
        pipeline_uri=pipeline_uri,
        nodes=nodes,
        edges=_parse_edges(g, pipeline),
        triggers=_parse_triggers(g, pipeline),
    )


def parse_report(g: Graph, report_uri: str) -> ReportDefinition:
    report = URIRef(report_uri)
    pipeline = _cf_value(g, report, "reportForPipeline")
    if not isinstance(pipeline, URIRef):
        raise ValueError(f"Report {report_uri} missing cf:reportForPipeline")

    observables: List[ObservableDefinition] = []
    for obs in _cf_objects(g, report, "hasObservable"):
        if not isinstance(obs, URIRef):
            continue
        node = _cf_value(g, obs, "observableNode")
        obs_type = _cf_value(g, obs, "observableType")
        if not isinstance(node, URIRef) or not isinstance(obs_type, URIRef):
            continue
        name = g.value(obs, SCHEMA.name)
        observables.append(
            ObservableDefinition(
                observable_uri=str(obs),
                observable_type_uri=str(obs_type),
                node_uri=str(node),
                name=str(name.toPython()) if isinstance(name, Literal) else None,
            )
        )
    return ReportDefinition(
        report_uri=report_uri,
        pipeline_uri=str(pipeline),
        observables=observables,
    )


def load_manifest_graph(path: str | Path, *, format: str | None = None) -> Graph:
    p = Path(path).expanduser().resolve()
    if not p.exists():
        raise FileNotFoundError(p)
    g = Dataset()
    guess = format
    if guess is None:
        if p.suffix.lower() in {".ttl", ".turtle"}:
            guess = "turtle"
        elif p.suffix.lower() in {_STRUCTURED_SUFFIX, ".json"}:
            guess = _STRUCTURED_FORMAT
        elif p.suffix.lower() in {".nq", ".nquads"}:
            guess = "nquads"
    g.parse(p.as_posix(), format=guess)
    _load_imports(g, base_dir=p.parent)
    return g


def _guess_format(path: Path) -> Optional[str]:
    suffix = path.suffix.lower()
    if suffix in {".ttl", ".turtle"}:
        return "turtle"
    if suffix in {_STRUCTURED_SUFFIX, ".json"}:
        return _STRUCTURED_FORMAT
    if suffix in {".nq", ".nquads"}:
        return "nquads"
    return None


def resolve_manifest_paths(root: Path) -> List[Path]:
    if root.is_file():
        return [root]
    manifests: List[Path] = []
    for ext in ("*.ttl", "*.turtle", "*.json", "*.nq", "*.nquads"):
        manifests.extend(root.glob(ext))
    return sorted(set(manifests))


def _resolve_import_uri(uri: str, base_dir: Path) -> tuple[Optional[Path], Optional[str]]:
    parsed = urlparse(uri)
    if parsed.scheme in {"", "file"}:
        if parsed.scheme == "file":
            path_str = unquote(parsed.path)
            if path_str.startswith("/") and len(path_str) >= 3 and path_str[2] == ":":
                path_str = path_str[1:]
            path = Path(path_str)
        else:
            path = Path(uri)
        if not path.is_absolute():
            path = (base_dir / path).resolve()
        return path, None
    return None, uri


def _load_imports(g: Graph, *, base_dir: Path, _loaded: Optional[set[str]] = None) -> None:
    loaded = _loaded if _loaded is not None else set()
    import_refs = list(g.objects(None, OWL.imports))
    for obj in import_refs:
        if not isinstance(obj, URIRef):
            continue
        path, uri = _resolve_import_uri(str(obj), base_dir)
        if path is not None:
            key = path.as_posix().lower()
            if key in loaded:
                continue
            if not path.exists():
                raise FileNotFoundError(f"owl:imports target not found: {path}")
            g.parse(path.as_posix(), format=_guess_format(path))
            loaded.add(key)
            _load_imports(g, base_dir=path.parent, _loaded=loaded)
            continue
        if uri:
            key = uri
            if key in loaded:
                continue
            g.parse(uri)
            loaded.add(key)
            _load_imports(g, base_dir=base_dir, _loaded=loaded)


def load_report_manifest(path: str | Path, *, report_uri: str | None = None, format: str | None = None) -> tuple[Graph, ReportDefinition, PipelineDefinition]:
    g = load_manifest_graph(path, format=format)
    if report_uri is None:
        reports = list(_iter_reports(g))
        if len(reports) != 1:
            raise ValueError(f"Manifest must contain exactly one cf:Report, found {len(reports)}")
        report_uri = str(reports[0])
    report = parse_report(g, report_uri)
    pipeline = parse_pipeline(g, report.pipeline_uri)
    return g, report, pipeline


def load_report_or_pipeline_manifest(
    path: str | Path,
    *,
    report_uri: str | None = None,
    format: str | None = None,
) -> tuple[Graph, ReportDefinition, PipelineDefinition]:
    g = load_manifest_graph(path, format=format)
    if report_uri is not None:
        report = parse_report(g, report_uri)
        pipeline = parse_pipeline(g, report.pipeline_uri)
        return g, report, pipeline

    reports = list(_iter_reports(g))
    if len(reports) == 1:
        report_uri = str(reports[0])
        report = parse_report(g, report_uri)
        pipeline = parse_pipeline(g, report.pipeline_uri)
        return g, report, pipeline
    if len(reports) > 1:
        raise ValueError(f"Manifest must contain exactly one cf:Report, found {len(reports)}")

    pipelines = list(_iter_pipelines(g))
    if len(pipelines) != 1:
        raise ValueError(f"Manifest must contain exactly one cf:ProcessingPipeline, found {len(pipelines)}")
    pipeline_uri = str(pipelines[0])
    pipeline = parse_pipeline(g, pipeline_uri)
    report = ReportDefinition(report_uri="", pipeline_uri=pipeline_uri, observables=[])
    return g, report, pipeline
