from __future__ import annotations

import time
import urllib.parse

from rdflib import Graph, Namespace
from rdflib.namespace import RDF

from cognite_data_quality._config import DataModelConfig, ValidationResult
from cognite_data_quality._neat import NeatSession
from cognite_data_quality._records import RecordsConfig, Violation, build_records, parse_focus_node_uri


def _instances_from_violations(
    violations: list[Violation],
    namespace_base: str = "http://purl.org/cognite/",
) -> list[dict]:
    """Build minimal instance-like dicts from violation focusNode URIs for record building.

    Supports NEAT format (space/{instance_space}#{urlencoded(external_id)})
    and simple format ({space}/{external_id}).
    """
    seen: set[tuple[str, str]] = set()
    instances: list[dict] = []
    for v in violations:
        parsed = parse_focus_node_uri(v.focusNode or "", namespace_base)
        if parsed is None:
            continue
        space, external_id = parsed
        key = (space, external_id)
        if key not in seen:
            seen.add(key)
            instances.append({"space": space, "externalId": external_id, "properties": {}})
    return instances


def validate_instances(
    *,
    client,
    shacl_rules: str,
    datamodel: DataModelConfig,
    instance_space: str,
    view_external_ids: list[str] | None = None,
    verbose: bool = True,
    limit: int | None = None,
    print_validation_output: bool = True,
    records_config: RecordsConfig | None = None,
    job_run_id: str | None = None,
    namespace_base: str = "http://purl.org/cognite/",
) -> ValidationResult:
    """Validate instances with SHACL by reading instance data directly from CDF.

    When view_external_ids is provided (from SHACL rule file sh:targetClass), loads
    instances from each view, verifies views exist in the data model, and dedupes by
    (space, external_id). Otherwise uses datamodel as a single view (legacy).

    limit: If set, only validate this many instances; None means no limit.
    print_validation_output: If True, NEAT may print per-instance output;
        if False, only summary/statistics are typically printed by the caller.
    """
    from cognite.client.data_classes import filters as dms_filters
    from cognite.client.data_classes.data_modeling import ViewId

    job_run_id = job_run_id or f"validation_{int(time.time())}"

    if verbose:
        print("Loading instances from CDF...")
        print(f"  Data model: {datamodel.space}/{datamodel.external_id}:{datamodel.version}")
        print(f"  Instance space: {instance_space}")

    instances: list[dict] = []
    if view_external_ids:
        # Views from rule file: add space/version from datamodel, verify in CDF, load per view
        view_ids = [
            ViewId(datamodel.space, view_ext_id, datamodel.version) for view_ext_id in sorted(view_external_ids)
        ]
        try:
            data_models = client.data_modeling.data_models.retrieve(
                ids=[(datamodel.space, datamodel.external_id, datamodel.version)],
                inline_views=True,
            )
        except Exception as e:
            raise RuntimeError(
                f"Failed to retrieve data model {datamodel.space}/{datamodel.external_id}:{datamodel.version}: {e}"
            ) from e
        if not data_models:
            raise ValueError(f"Data model {datamodel.space}/{datamodel.external_id}:{datamodel.version} not found")
        data_model = data_models[0]
        existing_views = {v.external_id for v in (data_model.views or [])}
        missing = set(view_external_ids) - existing_views
        if missing:
            raise ValueError(f"View(s) from SHACL rules not found in data model: {sorted(missing)}")
        seen: set[tuple[str, str]] = set()
        api_limit = limit if limit is not None else -1  # Load limit from each view

        for view_id in view_ids:
            try:
                result = client.data_modeling.instances.list(
                    instance_type="node",
                    sources=[view_id],
                    filter=dms_filters.Equals(["node", "space"], instance_space),
                    limit=api_limit,
                )
                for node in result:
                    key = (getattr(node, "space", ""), getattr(node, "external_id", ""))
                    if key not in seen:
                        seen.add(key)
                        instances.append(node.dump())
            except Exception as e:
                raise RuntimeError(f"Failed to load instances for view {view_id}: {e}") from e
        if verbose:
            print(f"  Loaded {len(instances)} instances (from {len(view_ids)} views)")
    else:
        # Legacy: single view = datamodel
        view_id = ViewId(datamodel.space, datamodel.external_id, datamodel.version)
        try:
            # Pass limit directly to API for single view case
            api_limit = limit if limit is not None else -1
            instances_result = client.data_modeling.instances.list(
                instance_type="node",
                sources=[view_id],
                filter=dms_filters.Equals(["node", "space"], instance_space),
                limit=api_limit,  # Pass limit to API instead of loading everything
            )
            instances = [node.dump() for node in instances_result]
        except Exception as e:
            raise RuntimeError(f"Failed to load instances from CDF: {e}") from e
        if verbose:
            print(f"  Loaded {len(instances)} instances")

    if not instances:
        if verbose:
            print("  No instances found - validation complete")
        return ValidationResult(
            conforms=True,
            violations=[],
            report_text="No instances to validate",
            instance_count=0,
            records=[],
        )

    # Final limit check (safety in case we loaded more than expected due to deduping buffer)
    if limit is not None and len(instances) > limit:
        instances = instances[:limit]
        if verbose:
            print(f"  Trimmed to {len(instances)} instances (limit={limit})")

    # Validate with SHACL
    neat = NeatSession(client, verbose=False)

    if verbose:
        print(f"Validating {len(instances)} instances with SHACL...")

    result = neat.validate_instances.with_shacl(
        instances=instances,
        shacl_rules=shacl_rules,
        datamodel_space=datamodel.space,
        datamodel_external_id=datamodel.external_id,
        datamodel_version=datamodel.version,
        auto_load_depth=2,
        verbose=print_validation_output,
    )

    conforms = result.conforms
    report_graph_str = result.report_graph
    report_text = result.report_text
    instance_count = len(instances)

    # Extract schema issues from validation result
    schema_issues_raw = getattr(result, "schema_issues", [])
    schema_issues = [issue.to_dict() if hasattr(issue, "to_dict") else issue.__dict__ for issue in schema_issues_raw]

    violations: list[Violation] = []

    # Build dimension lookup from SHACL rules (parse once, O(1) lookup per violation)
    from rdflib import BNode as _BNode

    _shape_to_dim: dict[str, str] = {}
    _path_to_dim: dict[str, str] = {}
    _path_to_shape: dict[str, str] = {}
    _shape_to_targetclass: dict[str, str] = {}
    try:
        _shacl_graph = Graph()
        _shacl_graph.parse(data=shacl_rules, format="turtle")
        _dq = Namespace("http://example.org/dataquality/")
        _sh_ns = Namespace("http://www.w3.org/ns/shacl#")
        for _shape, _dim in _shacl_graph.subject_objects(predicate=_dq.dimension):
            if isinstance(_shape, _BNode):
                # Anonymous property shape — use sh:path as proxy key
                for _path in _shacl_graph.objects(subject=_shape, predicate=_sh_ns.path):
                    _path_to_dim[str(_path)] = str(_dim)
            else:
                # Named shape (e.g., SPARQL constraint shape)
                _shape_to_dim[str(_shape)] = str(_dim)
        # Build path → parent named shape mapping (stable key across graph re-parsings)
        for _parent, _prop_bnode in _shacl_graph.subject_objects(predicate=_sh_ns.property):
            if not isinstance(_parent, _BNode):
                _prop_path = _shacl_graph.value(_prop_bnode, _sh_ns.path)
                if _prop_path and not isinstance(_prop_path, _BNode):
                    _path_to_shape[str(_prop_path)] = str(_parent)
        # Build shape → targetClass local name mapping (for filtering/aggregation)
        for _shape, _tc in _shacl_graph.subject_objects(predicate=_sh_ns.targetClass):
            if not isinstance(_shape, _BNode):
                _tc_str = str(_tc)
                _tc_local = _tc_str.split("#")[-1] if "#" in _tc_str else _tc_str.split("/")[-1]
                _shape_to_targetclass[str(_shape)] = _tc_local
    except Exception:
        pass  # Dimension extraction is best-effort; don't break validation

    if report_graph_str:
        if isinstance(report_graph_str, Graph):
            report_graph = report_graph_str
        elif not isinstance(report_graph_str, (str, bytes)):
            # pyshacl caught a ValidationFailure internally (e.g. a SPARQL constraint
            # crashed on unexpected data) and returned the exception object as report_graph.
            # Create one synthetic violation per instance so callers see the engine failure
            # rather than a confusing RuntimeError.
            report_graph = Graph()  # empty graph — violations populated synthetically below
            engine_error = str(report_graph_str)
            print(f"WARNING: SHACL engine failure: {engine_error}")
            namespace_base_local = namespace_base if namespace_base else "http://purl.org/cognite/"
            for inst in instances:
                inst_space = inst.get("space", "")
                inst_ext_id = inst.get("externalId", "")
                violations.append(
                    Violation(
                        focusNode=f"{namespace_base_local}{inst_space}/{urllib.parse.quote(inst_ext_id, safe='')}",
                        sourceConstraintComponent="SHACLEngineFailure",
                        resultMessage=f"SHACL engine failure prevented validation: {engine_error}",
                        resultSeverity="Violation",
                    )
                )
        else:
            report_graph = Graph()
            report_graph.parse(data=report_graph_str, format="turtle")
        sh = Namespace("http://www.w3.org/ns/shacl#")
        for res in report_graph.subjects(predicate=RDF.type, object=sh.ValidationResult):
            violation_data: dict[str, str] = {}
            for pred, obj in report_graph.predicate_objects(subject=res):
                pred_name = str(pred).split("#")[-1]
                if pred_name == "sourceShape" and isinstance(obj, _BNode):
                    pass  # blank node IDs don't survive re-parsing; resolve via resultPath below
                else:
                    violation_data[pred_name] = str(obj)
            # Resolve missing sourceShape via resultPath → parent named shape
            # (blank node IDs across different parse operations never match)
            if "sourceShape" not in violation_data:
                _rp = violation_data.get("resultPath")
                if _rp and _rp in _path_to_shape:
                    violation_data["sourceShape"] = _path_to_shape[_rp]
            violations.append(Violation(**violation_data))

        # Attach dimension and targetClass to each violation via dict lookup
        for violation in violations:
            if violation.sourceShape and violation.sourceShape in _shape_to_dim:
                violation.dimension = _shape_to_dim[violation.sourceShape]
            elif violation.resultPath and violation.resultPath in _path_to_dim:
                violation.dimension = _path_to_dim[violation.resultPath]
            if violation.sourceShape and violation.sourceShape in _shape_to_targetclass:
                violation.targetClass = _shape_to_targetclass[violation.sourceShape]

    records: list[dict] = []
    if records_config and records_config.rule_set_id and records_config.rule_set_version:
        # Build records for ALL instances (both pass and fail)
        # Pass the full instances list to create a record for each instance validated
        records = build_records(
            instances,  # Pass all instances, not just those with violations
            violations,
            job_run_id=job_run_id,
            records_config=records_config,
            namespace_base=namespace_base,
        )

        # Build records for schema issues using a different rule set ID
        if schema_issues_raw:
            from cognite_data_quality._records import build_schema_issue_records

            schema_records_config = RecordsConfig(
                stream_id=records_config.stream_id,
                rule_set_id=f"{records_config.rule_set_id}_SchemaConsistency",
                rule_set_version=records_config.rule_set_version,
                data_domain_external_id=records_config.data_domain_external_id,
                records_space=records_config.records_space,
                records_container=records_config.records_container,
            )
            schema_records = build_schema_issue_records(
                schema_issues_raw,
                job_run_id=job_run_id,
                records_config=schema_records_config,
                datamodel_space=datamodel.space,
                datamodel_external_id=datamodel.external_id,
                datamodel_version=datamodel.version,
            )
            records.extend(schema_records)

    return ValidationResult(
        conforms=conforms,
        violations=violations,
        report_text=report_text,
        instance_count=instance_count,
        records=records,
        schema_issues=schema_issues,
    )
