from __future__ import annotations

import copy
import fnmatch
from dataclasses import dataclass
from typing import Any

_CORE_PATHS = {
    "status",
    "nodes.*.partitions.*.row_count",
    "nodes.*.partitions.*.primary_keys_hash",
    "nodes.*.partitions.*.content_hash",
    "nodes.*.partitions.*.error_count",
}
_CORE_PATH_EXAMPLES = {
    "status",
    "nodes.example.partitions.example.row_count",
    "nodes.example.partitions.example.primary_keys_hash",
    "nodes.example.partitions.example.content_hash",
    "nodes.example.partitions.example.error_count",
}
_ROOT_KEYS = {
    "engine",
    "execution_id",
    "status",
    "duration_seconds",
    "resource_usage",
    "nodes",
}
_PARTITION_KEYS = {
    "row_count",
    "primary_keys_hash",
    "content_hash",
    "error_count",
    "duration_seconds",
    "resource_usage",
    "aggregates",
}


def _required_string(value: Any, label: str, maximum: int = 500) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ValueError(f"{label} is required")
    normalized = value.strip()
    if len(normalized) > maximum:
        raise ValueError(f"{label} exceeds {maximum} characters")
    return normalized


def _number(value: Any, label: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{label} must be numeric")
    return float(value)


def _numeric_map(value: Any, label: str, maximum: int = 100) -> dict[str, float]:
    if not isinstance(value, dict) or len(value) > maximum:
        raise ValueError(f"{label} must be a bounded object")
    return {
        _required_string(key, f"{label} key", 200): _number(
            item,
            f"{label}.{key}",
        )
        for key, item in value.items()
    }


@dataclass
class ExecutionSnapshot:
    engine: str
    execution_id: str
    status: str
    duration_seconds: float
    resource_usage: dict[str, float]
    nodes: dict[str, dict[str, Any]]

    @classmethod
    def from_dict(cls, value):
        if not isinstance(value, dict):
            raise ValueError("execution snapshot must be an object")
        unknown = sorted(set(value) - _ROOT_KEYS)
        if unknown:
            raise ValueError(
                f"execution snapshot contains unsupported fields: {', '.join(unknown)}"
            )
        engine = _required_string(value.get("engine"), "snapshot engine", 20)
        if engine not in {"n8n", "kestra"}:
            raise ValueError("unsupported snapshot engine")
        status = _required_string(value.get("status"), "snapshot status", 30)
        if status not in {
            "success",
            "failed",
            "cancelled",
            "killed",
            "unknown",
        }:
            raise ValueError("unsupported snapshot status")
        raw_nodes = value.get("nodes")
        if not isinstance(raw_nodes, dict) or len(raw_nodes) > 100:
            raise ValueError("snapshot nodes must be a bounded object")
        nodes = {}
        total_partitions = 0
        for node_id, raw_node in raw_nodes.items():
            normalized_node_id = _required_string(node_id, "snapshot node id", 100)
            if not isinstance(raw_node, dict) or set(raw_node) != {"partitions"}:
                raise ValueError("snapshot node must contain only partitions")
            raw_partitions = raw_node["partitions"]
            if not isinstance(raw_partitions, dict):
                raise ValueError("snapshot partitions must be an object")
            total_partitions += len(raw_partitions)
            if total_partitions > 1000:
                raise ValueError("snapshot partitions exceed limit")
            partitions = {}
            for partition, raw_partition in raw_partitions.items():
                partition_id = _required_string(
                    partition,
                    "snapshot partition",
                    300,
                )
                if not isinstance(raw_partition, dict):
                    raise ValueError("snapshot partition metrics must be an object")
                unknown_partition = sorted(set(raw_partition) - _PARTITION_KEYS)
                if unknown_partition:
                    raise ValueError(
                        "snapshot partition contains unsupported fields: "
                        + ", ".join(unknown_partition)
                    )
                if not {
                    "row_count",
                    "primary_keys_hash",
                    "content_hash",
                    "error_count",
                }.issubset(raw_partition):
                    raise ValueError("snapshot partition is missing core metrics")
                row_count = raw_partition["row_count"]
                error_count = raw_partition["error_count"]
                if (
                    isinstance(row_count, bool)
                    or not isinstance(row_count, int)
                    or row_count < 0
                ):
                    raise ValueError("row_count must be a non-negative integer")
                if (
                    isinstance(error_count, bool)
                    or not isinstance(error_count, int)
                    or error_count < 0
                ):
                    raise ValueError("error_count must be a non-negative integer")
                partitions[partition_id] = {
                    "row_count": row_count,
                    "primary_keys_hash": _required_string(
                        raw_partition["primary_keys_hash"],
                        "primary_keys_hash",
                        128,
                    ),
                    "content_hash": _required_string(
                        raw_partition["content_hash"],
                        "content_hash",
                        128,
                    ),
                    "error_count": error_count,
                    "duration_seconds": _number(
                        raw_partition.get("duration_seconds", 0),
                        "partition duration_seconds",
                    ),
                    "resource_usage": _numeric_map(
                        raw_partition.get("resource_usage", {}),
                        "partition resource_usage",
                    ),
                    "aggregates": _numeric_map(
                        raw_partition.get("aggregates", {}),
                        "partition aggregates",
                    ),
                }
            nodes[normalized_node_id] = {"partitions": partitions}
        return cls(
            engine=engine,
            execution_id=_required_string(
                value.get("execution_id"),
                "snapshot execution_id",
                255,
            ),
            status=status,
            duration_seconds=_number(
                value.get("duration_seconds", 0),
                "snapshot duration_seconds",
            ),
            resource_usage=_numeric_map(
                value.get("resource_usage", {}),
                "snapshot resource_usage",
            ),
            nodes=nodes,
        )


@dataclass(frozen=True, init=False)
class ReconciliationPolicy:
    ignored_paths: frozenset[str]
    numeric_tolerances: dict[str, float]

    def __init__(self, ignored_paths=None, numeric_tolerances=None):
        ignored = frozenset(str(item).strip() for item in (ignored_paths or set()))
        if "" in ignored or len(ignored) > 100:
            raise ValueError("ignored paths must be a bounded set")
        for path in ignored:
            if (
                path in _CORE_PATHS
                or any(
                    fnmatch.fnmatchcase(example, path)
                    for example in _CORE_PATH_EXAMPLES
                )
                or any(
                    fnmatch.fnmatchcase(path, core_pattern)
                    for core_pattern in _CORE_PATHS
                )
            ):
                raise ValueError("core reconciliation metric cannot be ignored")
        tolerances = {}
        for path, value in dict(numeric_tolerances or {}).items():
            normalized_path = _required_string(
                path,
                "numeric tolerance path",
                500,
            )
            tolerance = _number(value, f"numeric tolerance {normalized_path}")
            if tolerance < 0:
                raise ValueError("numeric tolerance cannot be negative")
            tolerances[normalized_path] = tolerance
        object.__setattr__(self, "ignored_paths", ignored)
        object.__setattr__(self, "numeric_tolerances", tolerances)

    def ignored(self, path):
        return any(
            fnmatch.fnmatchcase(path, pattern) for pattern in self.ignored_paths
        )

    def tolerance(self, path):
        matches = [
            value
            for pattern, value in self.numeric_tolerances.items()
            if fnmatch.fnmatchcase(path, pattern)
        ]
        return min(matches) if matches else 0.0

    def as_dict(self):
        return {
            "ignored_paths": sorted(self.ignored_paths),
            "numeric_tolerances": dict(sorted(self.numeric_tolerances.items())),
        }


class WorkflowReconciler:
    @staticmethod
    def _difference(
        differences,
        *,
        node_id,
        partition,
        metric,
        baseline,
        candidate,
        path,
        policy,
    ):
        if policy.ignored(path):
            return
        if isinstance(baseline, (int, float)) and isinstance(
            candidate,
            (int, float),
        ):
            delta = float(candidate) - float(baseline)
            tolerance = policy.tolerance(path)
            if abs(delta) <= tolerance:
                return
            differences.append(
                {
                    "node_id": node_id,
                    "partition": partition,
                    "metric": metric,
                    "baseline": baseline,
                    "candidate": candidate,
                    "delta": delta,
                    "tolerance": tolerance,
                }
            )
            return
        if baseline != candidate:
            differences.append(
                {
                    "node_id": node_id,
                    "partition": partition,
                    "metric": metric,
                    "baseline": baseline,
                    "candidate": candidate,
                }
            )

    def compare(self, baseline, candidate, policy):
        if not isinstance(baseline, ExecutionSnapshot) or not isinstance(
            candidate,
            ExecutionSnapshot,
        ):
            raise ValueError("reconciliation requires execution snapshots")
        if not isinstance(policy, ReconciliationPolicy):
            raise ValueError("reconciliation policy is required")
        differences = []
        self._difference(
            differences,
            node_id=None,
            partition=None,
            metric="status",
            baseline=baseline.status,
            candidate=candidate.status,
            path="status",
            policy=policy,
        )
        self._difference(
            differences,
            node_id=None,
            partition=None,
            metric="duration_seconds",
            baseline=baseline.duration_seconds,
            candidate=candidate.duration_seconds,
            path="duration_seconds",
            policy=policy,
        )
        for key in sorted(
            set(baseline.resource_usage) | set(candidate.resource_usage)
        ):
            self._difference(
                differences,
                node_id=None,
                partition=None,
                metric=f"resource_usage.{key}",
                baseline=baseline.resource_usage.get(key),
                candidate=candidate.resource_usage.get(key),
                path=f"resource_usage.{key}",
                policy=policy,
            )

        compared_partitions = 0
        baseline_rows = 0
        candidate_rows = 0
        node_ids = sorted(set(baseline.nodes) | set(candidate.nodes))
        for node_id in node_ids:
            baseline_partitions = baseline.nodes.get(node_id, {}).get(
                "partitions",
                {},
            )
            candidate_partitions = candidate.nodes.get(node_id, {}).get(
                "partitions",
                {},
            )
            partitions = sorted(
                set(baseline_partitions) | set(candidate_partitions)
            )
            for partition in partitions:
                before = baseline_partitions.get(partition)
                after = candidate_partitions.get(partition)
                if before is not None:
                    baseline_rows += before["row_count"]
                if after is not None:
                    candidate_rows += after["row_count"]
                if before is None or after is None:
                    differences.append(
                        {
                            "node_id": node_id,
                            "partition": partition,
                            "metric": "partition_presence",
                            "baseline": before is not None,
                            "candidate": after is not None,
                        }
                    )
                    continue
                compared_partitions += 1
                for metric in (
                    "row_count",
                    "primary_keys_hash",
                    "content_hash",
                    "error_count",
                ):
                    path = f"nodes.{node_id}.partitions.{partition}.{metric}"
                    self._difference(
                        differences,
                        node_id=node_id,
                        partition=partition,
                        metric=metric,
                        baseline=before[metric],
                        candidate=after[metric],
                        path=path,
                        policy=policy,
                    )
                duration_path = (
                    f"nodes.{node_id}.partitions.{partition}."
                    "duration_seconds"
                )
                self._difference(
                    differences,
                    node_id=node_id,
                    partition=partition,
                    metric="duration_seconds",
                    baseline=before["duration_seconds"],
                    candidate=after["duration_seconds"],
                    path=duration_path,
                    policy=policy,
                )
                for group in ("aggregates", "resource_usage"):
                    keys = sorted(set(before[group]) | set(after[group]))
                    for key in keys:
                        path = (
                            f"nodes.{node_id}.partitions.{partition}."
                            f"{group}.{key}"
                        )
                        self._difference(
                            differences,
                            node_id=node_id,
                            partition=partition,
                            metric=f"{group}.{key}",
                            baseline=before[group].get(key),
                            candidate=after[group].get(key),
                            path=path,
                            policy=policy,
                        )

        equivalent = not differences
        return {
            "status": "passed" if equivalent else "failed",
            "equivalent": equivalent,
            "baseline_execution_id": baseline.execution_id,
            "candidate_execution_id": candidate.execution_id,
            "baseline_engine": baseline.engine,
            "candidate_engine": candidate.engine,
            "difference_count": len(differences),
            "differences": copy.deepcopy(differences),
            "metrics": {
                "compared_partitions": compared_partitions,
                "baseline_row_count": baseline_rows,
                "candidate_row_count": candidate_rows,
                "row_count_delta": candidate_rows - baseline_rows,
                "duration_delta_seconds": (
                    candidate.duration_seconds - baseline.duration_seconds
                ),
                "resource_usage": {
                    key: candidate.resource_usage.get(key, 0)
                    - baseline.resource_usage.get(key, 0)
                    for key in sorted(
                        set(baseline.resource_usage)
                        | set(candidate.resource_usage)
                    )
                },
            },
            "policy": policy.as_dict(),
        }
