"""
Cognite Function for cursor-based SHACL validation of DMS instances.

Unlike the standard instance validation mode (which receives instances in the workflow
payload), this handler maintains its own sync cursor in DMS and independently fetches
all pending changes since the last run.

The workflow trigger fires as a lightweight signal (no view properties fetched).
The handler then:
  1. Loads the saved sync cursor from FunctionValidationState
  2. Queries DMS via instances.sync() for all nodes changed since the cursor
  3. Validates each chunk with SHACL rules (NeatSession auto_load_depth loads properties)
  4. Posts results to the Records API
  5. Saves the updated cursor for the next invocation

Supports:
- Cursor-based resumption on timeout (same 480s safety margin as partitioned mode)
- initial_sync_cutoff filter on first run to align with historic validation cutoff
- SHACL dimension lookup for enriched violation metadata
"""

import time
import urllib.parse
from typing import Any

from cognite.client.data_classes.data_modeling import ViewId
from cognite.client.data_classes.data_modeling.query import NodeResultSetExpression, Query, Select, SourceSelector
from cognite.client.data_classes.filters import And, Equals, HasData, Range
from common.cursor_state import load_sync_cursor, save_sync_cursor
from common.records_api import post_validation_results_to_records
from common.shacl_utils import load_shacl_from_file


def handle_instance_sync_cursor_validation(data: dict[str, Any], client) -> dict[str, Any]:
    """
    Validate DMS instances using a persistent sync cursor instead of payload instances.

    Args:
        data: Input data containing:
            - view_space: Space of the view to sync
            - view_external_id: External ID of the view
            - view_version: Version of the view
            - instance_space: Space where instances live

            - shacl_rules_file_external_id: External ID of SHACL rules file in CDF
            - datamodel_space: Space of the data model
            - datamodel_external_id: External ID of the data model
            - datamodel_version: Version of the data model

            - auto_load_depth: (optional) Depth for auto-loading references (default: 2)
            - chunk_size: (optional) Instances per sync chunk (default: 200)
            - max_runtime_seconds: (optional) Timeout safety margin (default: 480)
            - initial_sync_cutoff: (optional) ISO timestamp; first-run filter applied when
                                   no cursor exists. Set by orchestrator via create_sync_trigger().
            - verbose: (optional) Print progress (default: True)

            - records_config: Dict with stream_id, rule_set_id, rule_set_version,
                              records_space, records_container
            - data_quality_space: (optional) Space for cursor state (default: "dataQuality")

        client: Cognite client instance

    Returns:
        Dict with status, instances_validated, total_violations, records_posted,
        cursor_saved, elapsed_seconds.
    """
    from cognite_data_quality._neat import NeatSession

    print("=" * 80)
    print("INSTANCE SYNC CURSOR VALIDATION")
    print("=" * 80)

    view_space = data.get("view_space")
    view_external_id = data.get("view_external_id")
    view_version = data.get("view_version")
    instance_space = data.get("instance_space")

    datamodel_space = data.get("datamodel_space")
    datamodel_external_id = data.get("datamodel_external_id")
    datamodel_version = data.get("datamodel_version")

    shacl_file_external_id = data.get("shacl_rules_file_external_id")
    records_config = data.get("records_config", {})
    stream_id = records_config.get("stream_id")
    rule_set_id = records_config.get("rule_set_id")
    rule_set_version = records_config.get("rule_set_version")
    records_space = records_config.get("records_space", "dataQuality")
    records_container = records_config.get("records_container", "DataQualityValidationRecord")

    data_quality_space = data.get("data_quality_space", "dataQuality")
    auto_load_depth = data.get("auto_load_depth", 2)
    chunk_size = data.get("chunk_size", 200)
    max_runtime_seconds = data.get("max_runtime_seconds", 480)
    initial_sync_cutoff = data.get("initial_sync_cutoff")
    verbose = data.get("verbose", True)
    job_run_id = data.get("job_run_id") or f"job_{int(time.time())}"

    print(f"View: {view_space}/{view_external_id}/{view_version}")
    print(f"Instance space: {instance_space}")
    print(f"Data model: {datamodel_space}/{datamodel_external_id}/{datamodel_version}")
    print(f"Chunk size: {chunk_size}, Max runtime: {max_runtime_seconds}s")
    print(f"Initial sync cutoff: {initial_sync_cutoff}")
    print(f"Job run ID: {job_run_id}")

    start_time = time.time()

    # Load SHACL rules
    try:
        print(f"Loading SHACL rules from: {shacl_file_external_id}")
        shacl_rules = load_shacl_from_file(client, shacl_file_external_id)
        print(f"  Loaded {len(shacl_rules)} characters")
    except Exception as e:
        return {"status": "error", "error": f"Failed to load SHACL rules: {e!s}"}

    # Build dimension lookup from SHACL rules (parsed once; O(1) lookup per violation)
    from rdflib import BNode as _BNode
    from rdflib import Graph as _Graph
    from rdflib import Namespace as _Namespace

    _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):
                for _path in _shacl_graph.objects(subject=_shape, predicate=_sh_ns.path):
                    _path_to_dim[str(_path)] = str(_dim)
            else:
                _shape_to_dim[str(_shape)] = str(_dim)
        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)
        for _shape, _tc in _shacl_graph.subject_objects(predicate=_sh_ns.targetClass):
            if not isinstance(_shape, _BNode):
                _tc_str = str(_tc)
                _shape_to_targetclass[str(_shape)] = (
                    _tc_str.split("#")[-1] if "#" in _tc_str else _tc_str.split("/")[-1]
                )
        print(f"  Dimension lookup: {len(_shape_to_dim)} named shapes, {len(_path_to_dim)} property paths")
    except Exception as _dim_err:
        print(f"  Warning: Could not build dimension lookup: {_dim_err}")

    # Load saved sync cursor
    print("Loading sync cursor state...")
    cursor_state = load_sync_cursor(
        client, view_space, view_external_id, instance_space, job_run_id, data_quality_space
    )
    current_cursor = cursor_state["cursor"] if cursor_state else None
    total_processed = cursor_state["processed_count"] if cursor_state else 0

    if current_cursor:
        print(f"  ✓ Resuming from cursor (already processed: {total_processed})")
    else:
        print("  ✗ No cursor found — starting fresh")
        if initial_sync_cutoff:
            print(f"  Applying initial_sync_cutoff filter: lastUpdatedTime >= {initial_sync_cutoff}")

    # Build DMS filter
    view_id = ViewId(view_space, view_external_id, view_version)
    filters = [HasData(views=[view_id]), Equals(["node", "space"], instance_space)]
    if not current_cursor and initial_sync_cutoff:
        # First run only: limit to instances updated after the historic cutoff so we
        # don't re-validate instances already covered by the historic orchestrator.
        filters.append(Range(property=["node", "lastUpdatedTime"], gte=initial_sync_cutoff))
    dms_filter = And(*filters)

    neat = NeatSession(client, verbose=verbose)

    total_instances_validated = 0
    total_violations_found = 0
    total_records_posted = 0
    chunk_num = 0

    _base_limits = [1000, 500, 200, 100, 10]
    SYNC_LIMITS = [chunk_size] + [v for v in _base_limits if v < chunk_size]
    sync_limit_idx = 0

    try:
        while True:
            elapsed = time.time() - start_time
            if elapsed > max_runtime_seconds:
                print(f"Approaching timeout ({elapsed:.1f}s), saving cursor and exiting...")
                if current_cursor:
                    save_sync_cursor(
                        client,
                        view_space,
                        view_external_id,
                        instance_space,
                        current_cursor,
                        total_processed + total_instances_validated,
                        job_run_id,
                        data_quality_space,
                    )
                return {
                    "status": "partial",
                    "instances_validated": total_instances_validated,
                    "total_violations": total_violations_found,
                    "records_posted": total_records_posted,
                    "cursor_saved": bool(current_cursor),
                    "elapsed_seconds": elapsed,
                }

            chunk_num += 1
            print(f"--- Chunk {chunk_num} (elapsed: {elapsed:.1f}s) ---")

            result = None
            retries_at_limit = 0
            while True:
                sync_limit = SYNC_LIMITS[sync_limit_idx]
                try:
                    query = Query(
                        with_={
                            "nodes": NodeResultSetExpression(
                                filter=dms_filter,
                                limit=sync_limit,
                                sync_mode="two_phase",
                            )
                        },
                        select={"nodes": Select(sources=[SourceSelector(source=view_id, properties=[])])},
                        cursors={"nodes": current_cursor} if current_cursor else None,
                    )
                    result = client.data_modeling.instances.sync(query=query)
                    break
                except Exception as e:
                    error_msg = str(e).lower()
                    if "timeout" in error_msg or "timed out" in error_msg:
                        retries_at_limit += 1
                        if retries_at_limit < 2:
                            print(f"  Sync timeout (attempt {retries_at_limit}/2), retrying in 30s...")
                            time.sleep(30)
                        else:
                            retries_at_limit = 0
                            sync_limit_idx += 1
                            if sync_limit_idx >= len(SYNC_LIMITS):
                                print("  All sync limits exhausted — saving cursor and aborting")
                                if current_cursor:
                                    save_sync_cursor(
                                        client,
                                        view_space,
                                        view_external_id,
                                        instance_space,
                                        current_cursor,
                                        total_processed + total_instances_validated,
                                        job_run_id,
                                        data_quality_space,
                                    )
                                return {
                                    "status": "sync_limits_exhausted",
                                    "instances_validated": total_instances_validated,
                                    "total_violations": total_violations_found,
                                    "records_posted": total_records_posted,
                                    "cursor_saved": bool(current_cursor),
                                }
                            print(
                                f"  Sync timeout (2/2), reducing limit:"
                                f" {sync_limit} → {SYNC_LIMITS[sync_limit_idx]}, waiting 30s..."
                            )
                            time.sleep(30)
                    else:
                        raise

            # Extract instances (with view properties via SourceSelector)
            instances = []
            if hasattr(result, "data") and "nodes" in result.data:
                node_objects = result.data["nodes"]
                instances = [node.dump(camel_case=True) if hasattr(node, "dump") else node for node in node_objects]

            new_cursor = None
            if hasattr(result, "cursors"):
                cursors_dict = result.cursors.__dict__ if hasattr(result.cursors, "__dict__") else result.cursors
                if isinstance(cursors_dict, dict):
                    new_cursor = cursors_dict.get("nodes")

            has_more = new_cursor is not None and new_cursor != current_cursor
            current_cursor = new_cursor

            # Filter deleted instances
            instances = [inst for inst in instances if not inst.get("deletedTime")]

            print(f"  Fetched {len(instances)} instances (has_more: {has_more})")

            if not instances:
                print("  No more instances to process")
                if current_cursor:
                    save_sync_cursor(
                        client,
                        view_space,
                        view_external_id,
                        instance_space,
                        current_cursor,
                        total_processed + total_instances_validated,
                        job_run_id,
                        data_quality_space,
                    )
                break

            # Validate chunk
            _conforms, report_graph, _report_text = 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=auto_load_depth,
                verbose=verbose,
            )

            # Extract violations
            violations = []
            if report_graph:
                from rdflib import Graph, Namespace

                if isinstance(report_graph, (str, bytes)):
                    # Normal case: pyshacl returned a Turtle-serialised report — parse it
                    parsed = Graph()
                    parsed.parse(data=report_graph, format="turtle")
                    report_graph = parsed
                elif not isinstance(report_graph, Graph):
                    # pyshacl caught a ValidationFailure internally and returned the exception
                    # object as report_graph.  Create one synthetic violation per instance so
                    # Records API reflects that these instances could not be validated.
                    engine_error = str(report_graph)
                    print(f"  WARNING: SHACL engine failure for this chunk: {engine_error}")
                    for inst in instances:
                        inst_space = inst.get("space", "")
                        inst_ext_id = inst.get("externalId", "")
                        violations.append(
                            {
                                "focusNode": (
                                    f"http://purl.org/cognite/{inst_space}/{urllib.parse.quote(inst_ext_id, safe='')}"
                                ),
                                "sourceConstraintComponent": "SHACLEngineFailure",
                                "resultMessage": f"SHACL engine failure prevented validation: {engine_error}",
                                "resultSeverity": "Violation",
                            }
                        )

                if isinstance(report_graph, Graph):
                    SH = Namespace("http://www.w3.org/ns/shacl#")
                    for result_node in report_graph.subjects(predicate=None, object=SH.ValidationResult):
                        violation = {}
                        for pred, obj in report_graph.predicate_objects(subject=result_node):
                            pred_name = str(pred).split("#")[-1]
                            if pred_name == "sourceShape" and isinstance(obj, _BNode):
                                pass
                            else:
                                violation[pred_name] = str(obj)
                        if "sourceShape" not in violation:
                            _rp = violation.get("resultPath")
                            if _rp and _rp in _path_to_shape:
                                violation["sourceShape"] = _path_to_shape[_rp]
                        violations.append(violation)

            print(f"  Validated {len(instances)} instances, found {len(violations)} violations")

            # Attach dimension and targetClass
            for v in violations:
                source_shape = v.get("sourceShape", "")
                result_path = v.get("resultPath", "")
                if source_shape and source_shape in _shape_to_dim:
                    v["dimension"] = _shape_to_dim[source_shape]
                elif result_path and result_path in _path_to_dim:
                    v["dimension"] = _path_to_dim[result_path]
                if source_shape and source_shape in _shape_to_targetclass:
                    v["targetClass"] = _shape_to_targetclass[source_shape]

            # Post to Records API
            posted_count, record_errors = post_validation_results_to_records(
                client=client,
                all_instances=instances,
                violations=violations,
                job_run_id=job_run_id,
                stream_id=stream_id,
                rule_set_id=rule_set_id,
                rule_set_version=rule_set_version,
                namespace_base="http://purl.org/cognite/",
                records_space=records_space,
                records_container=records_container,
            )

            print(f"  Posted {posted_count} records")
            if record_errors:
                print(f"  WARNING: {len(record_errors)} record(s) failed to post:")
                for err in record_errors[:5]:
                    print(f"    - {err['instance']}: {err['error']}")
                if len(record_errors) > 5:
                    print(f"    ... and {len(record_errors) - 5} more")

                failure_rate = len(record_errors) / len(instances)
                failure_threshold = 0.1
                if failure_rate > failure_threshold:
                    raise RuntimeError(
                        f"Failed to post {len(record_errors)}/{len(instances)} records "
                        f"({failure_rate:.1%} failure rate exceeds {failure_threshold:.1%} threshold). "
                        f"Validation results cannot be persisted reliably."
                    )

            total_instances_validated += len(instances)
            total_violations_found += len(violations)
            total_records_posted += posted_count

            # Save cursor after each successful chunk (cursor is kept between invocations)
            if current_cursor:
                try:
                    save_sync_cursor(
                        client,
                        view_space,
                        view_external_id,
                        instance_space,
                        current_cursor,
                        total_processed + total_instances_validated,
                        job_run_id,
                        data_quality_space,
                    )
                except Exception as cursor_error:
                    raise RuntimeError(
                        f"Failed to save sync cursor after processing {total_instances_validated} instances. "
                        f"Cannot continue without progress tracking. Error: {cursor_error}"
                    ) from cursor_error

            if not has_more:
                print("  Reached end of current sync position")
                break

        elapsed = time.time() - start_time
        cumulative_total = total_processed + total_instances_validated

        print("=" * 80)
        print("SYNC CURSOR VALIDATION COMPLETED")
        print("=" * 80)
        print(f"Instances validated (this run): {total_instances_validated}")
        print(f"Cumulative validated: {cumulative_total}")
        print(f"Total violations: {total_violations_found}")
        print(f"Records posted: {total_records_posted}")
        print(f"Elapsed time: {elapsed:.1f}s")
        print("=" * 80)

        return {
            "status": "completed",
            "instances_validated": total_instances_validated,
            "cumulative_validated": cumulative_total,
            "total_violations": total_violations_found,
            "records_posted": total_records_posted,
            "cursor_saved": bool(current_cursor),
            "elapsed_seconds": elapsed,
        }

    except Exception as e:
        import traceback

        error_msg = f"Error in sync cursor validation: {e!s}\n{traceback.format_exc()}"
        print(f"{'=' * 80}")
        print("SYNC CURSOR VALIDATION FAILED")
        print(f"{'=' * 80}")
        print(error_msg)

        if current_cursor:
            save_sync_cursor(
                client,
                view_space,
                view_external_id,
                instance_space,
                current_cursor,
                total_processed + total_instances_validated,
                job_run_id,
                data_quality_space,
            )
            print("Cursor saved for retry")

        return {
            "status": "error",
            "error": error_msg,
            "instances_validated": total_instances_validated,
            "total_violations": total_violations_found,
            "records_posted": total_records_posted,
            "cursor_saved": bool(current_cursor),
        }
