from __future__ import annotations

import copy
import hashlib
import json
import re
from collections import deque
from typing import Any

from app.core.common.identifiers import ensure_governance_uid


SCHEMA_VERSION = "1.0"
NODE_TYPES = {
    "sql.query",
    "sql.execute",
    "python",
    "http",
    "condition",
    "parallel",
    "subflow",
    "notify",
}
SECRET_KEY_NAMES = {
    "apikey",
    "authorization",
    "connectionstring",
    "credential",
    "credentials",
    "dsn",
    "password",
    "secret",
    "token",
}
WORKFLOW_ROOT_KEYS = {
    "schema_version",
    "dataflow_uid",
    "name",
    "description",
    "nodes",
    "edges",
    "parameters",
    "labels",
}
NODE_KEYS = {
    "id",
    "type",
    "data_source_uid",
    "purpose",
    "config",
    "idempotency",
}
EDGE_KEYS = {"from", "to", "condition"}
SCHEDULE_ROOT_KEYS = {
    "schema_version",
    "timezone",
    "triggers",
    "max_concurrency",
    "conflict_policy",
    "timeout_seconds",
    "retry",
    "backfill",
}
CONFLICT_POLICIES = {"skip", "queue", "cancel_previous"}


WORKFLOW_SPEC_SCHEMA = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://dataops.local/schemas/workflow-spec-1.0.json",
    "title": "DataOps WorkflowSpec",
    "type": "object",
    "additionalProperties": False,
    "required": [
        "schema_version",
        "dataflow_uid",
        "name",
        "nodes",
        "edges",
        "parameters",
    ],
    "properties": {
        "schema_version": {"const": SCHEMA_VERSION},
        "dataflow_uid": {"type": "string", "format": "uuid"},
        "name": {"type": "string", "minLength": 1, "maxLength": 200},
        "description": {"type": "string", "maxLength": 2000},
        "nodes": {
            "type": "array",
            "minItems": 1,
            "items": {"$ref": "#/$defs/node"},
        },
        "edges": {"type": "array", "items": {"$ref": "#/$defs/edge"}},
        "parameters": {"type": "object"},
        "labels": {"type": "object"},
    },
    "$defs": {
        "node": {
            "type": "object",
            "additionalProperties": False,
            "required": ["id", "type", "config"],
            "properties": {
                "id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,99}$"},
                "type": {"type": "string", "enum": sorted(NODE_TYPES)},
                "data_source_uid": {"type": "string", "format": "uuid"},
                "purpose": {"type": "string", "enum": ["read", "write"]},
                "config": {"type": "object"},
                "idempotency": {"type": "object"},
            },
        },
        "edge": {
            "type": "object",
            "additionalProperties": False,
            "required": ["from", "to"],
            "properties": {
                "from": {"type": "string"},
                "to": {"type": "string"},
                "condition": {"type": "string"},
            },
        },
    },
}


SCHEDULE_PLAN_SCHEMA = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://dataops.local/schemas/schedule-plan-1.0.json",
    "title": "DataOps SchedulePlan",
    "type": "object",
    "additionalProperties": False,
    "required": [
        "schema_version",
        "timezone",
        "triggers",
        "max_concurrency",
        "conflict_policy",
        "timeout_seconds",
        "retry",
        "backfill",
    ],
    "properties": {
        "schema_version": {"const": SCHEMA_VERSION},
        "timezone": {"type": "string"},
        "triggers": {"type": "array", "minItems": 1},
        "max_concurrency": {"type": "integer", "minimum": 1, "maximum": 100},
        "conflict_policy": {"enum": sorted(CONFLICT_POLICIES)},
        "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 86400},
        "retry": {"type": "object"},
        "backfill": {"type": "object"},
    },
}


def _closed_object(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
    if not isinstance(value, dict):
        raise ValueError(f"{label} must be an object")
    unknown = sorted(set(value) - allowed)
    if unknown:
        raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
    return value


def _required_string(value: Any, label: str, *, maximum: int = 200) -> 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 _validate_uid(value: Any, label: str) -> str:
    try:
        return ensure_governance_uid({"uid": str(value)})
    except ValueError as exc:
        raise ValueError(f"{label} must be a valid UUIDv7") from exc


def _normalized_secret_key(value: Any) -> str:
    return re.sub(r"[^a-z0-9]", "", str(value).lower())


def _reject_secret_material(value: Any, path: str = "$") -> None:
    if isinstance(value, dict):
        for key, item in value.items():
            if _normalized_secret_key(key) in SECRET_KEY_NAMES:
                raise ValueError(f"secret material is not allowed at {path}.{key}")
            _reject_secret_material(item, f"{path}.{key}")
    elif isinstance(value, list):
        for index, item in enumerate(value):
            _reject_secret_material(item, f"{path}[{index}]")


def _validate_acyclic(node_ids: set[str], edges: list[dict[str, Any]]) -> None:
    indegree = {node_id: 0 for node_id in node_ids}
    adjacency = {node_id: [] for node_id in node_ids}
    seen_edges = set()
    for edge in edges:
        source = edge["from"]
        target = edge["to"]
        if source not in node_ids or target not in node_ids:
            raise ValueError("workflow edge references an unknown node")
        pair = (source, target)
        if pair in seen_edges:
            raise ValueError("workflow graph contains a duplicate edge")
        seen_edges.add(pair)
        adjacency[source].append(target)
        indegree[target] += 1
    queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
    visited = 0
    while queue:
        current = queue.popleft()
        visited += 1
        for target in adjacency[current]:
            indegree[target] -= 1
            if indegree[target] == 0:
                queue.append(target)
    if visited != len(node_ids):
        raise ValueError("workflow graph must be acyclic")


def validate_workflow_spec(spec: Any) -> dict[str, Any]:
    payload = copy.deepcopy(
        _closed_object(spec, WORKFLOW_ROOT_KEYS, "workflow spec")
    )
    if payload.get("schema_version") != SCHEMA_VERSION:
        raise ValueError(f"workflow spec schema_version must be {SCHEMA_VERSION}")
    payload["dataflow_uid"] = _validate_uid(
        payload.get("dataflow_uid"), "dataflow_uid"
    )
    payload["name"] = _required_string(payload.get("name"), "workflow name")
    if "description" in payload:
        payload["description"] = _required_string(
            payload["description"], "workflow description", maximum=2000
        )
    nodes = payload.get("nodes")
    if not isinstance(nodes, list) or not nodes:
        raise ValueError("workflow nodes must be a non-empty array")
    normalized_nodes = []
    node_ids = set()
    for raw_node in nodes:
        node = copy.deepcopy(_closed_object(raw_node, NODE_KEYS, "workflow node"))
        node_id = _required_string(node.get("id"), "node id", maximum=100)
        if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,99}", node_id):
            raise ValueError("node id contains unsupported characters")
        if node_id in node_ids:
            raise ValueError("workflow node ids must be unique")
        node_ids.add(node_id)
        node_type = _required_string(node.get("type"), "node type", maximum=100)
        if node_type not in NODE_TYPES:
            raise ValueError(f"unsupported node type: {node_type}")
        config = node.get("config")
        if not isinstance(config, dict):
            raise ValueError("node config must be an object")
        node["id"] = node_id
        node["type"] = node_type
        if node_type in {"sql.query", "sql.execute"}:
            node["data_source_uid"] = _validate_uid(
                node.get("data_source_uid"), "node data_source_uid"
            )
        if node_type == "sql.query" and node.get("purpose") != "read":
            raise ValueError("sql.query node purpose must be read")
        if node_type == "sql.execute":
            if node.get("purpose") != "write":
                raise ValueError("sql.execute node purpose must be write")
            idempotency = node.get("idempotency")
            if not isinstance(idempotency, dict):
                raise ValueError("write node requires idempotency")
            strategy = idempotency.get("strategy")
            key = idempotency.get("key")
            if strategy not in {"partition_replace", "upsert", "deduplication_key"}:
                raise ValueError("write node requires idempotency strategy")
            _required_string(key, "write node idempotency key", maximum=500)
        normalized_nodes.append(node)
    payload["nodes"] = normalized_nodes

    raw_edges = payload.get("edges")
    if not isinstance(raw_edges, list):
        raise ValueError("workflow edges must be an array")
    edges = []
    for raw_edge in raw_edges:
        edge = copy.deepcopy(_closed_object(raw_edge, EDGE_KEYS, "workflow edge"))
        edge["from"] = _required_string(edge.get("from"), "edge from", maximum=100)
        edge["to"] = _required_string(edge.get("to"), "edge to", maximum=100)
        if "condition" in edge:
            edge["condition"] = _required_string(
                edge["condition"], "edge condition", maximum=500
            )
        edges.append(edge)
    _validate_acyclic(node_ids, edges)
    payload["edges"] = edges

    parameters = payload.get("parameters")
    if not isinstance(parameters, dict):
        raise ValueError("workflow parameters must be an object")
    if "labels" in payload and not isinstance(payload["labels"], dict):
        raise ValueError("workflow labels must be an object")
    _reject_secret_material(payload)
    return payload


def workflow_spec_hash(spec: Any) -> str:
    canonical = json.dumps(
        validate_workflow_spec(spec),
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def _positive_bounded_integer(
    value: Any, label: str, *, minimum: int, maximum: int
) -> int:
    if isinstance(value, bool) or not isinstance(value, int):
        raise ValueError(f"{label} must be an integer")
    if value < minimum or value > maximum:
        raise ValueError(f"{label} must be between {minimum} and {maximum}")
    return value


def validate_schedule_plan(plan: Any) -> dict[str, Any]:
    payload = copy.deepcopy(
        _closed_object(plan, SCHEDULE_ROOT_KEYS, "schedule plan")
    )
    if payload.get("schema_version") != SCHEMA_VERSION:
        raise ValueError(f"schedule plan schema_version must be {SCHEMA_VERSION}")
    payload["timezone"] = _required_string(
        payload.get("timezone"), "schedule timezone", maximum=100
    )
    triggers = payload.get("triggers")
    if not isinstance(triggers, list) or not triggers:
        raise ValueError("schedule triggers must be a non-empty array")
    normalized_triggers = []
    for raw_trigger in triggers:
        trigger = _closed_object(
            copy.deepcopy(raw_trigger),
            {"type", "expression", "at", "event_type"},
            "schedule trigger",
        )
        trigger_type = trigger.get("type")
        if trigger_type not in {"manual", "cron", "at", "event"}:
            raise ValueError("unsupported schedule trigger type")
        if trigger_type == "cron":
            expression = _required_string(
                trigger.get("expression"), "cron expression", maximum=200
            )
            if len(expression.split()) not in {5, 6, 7}:
                raise ValueError("cron expression must have 5, 6, or 7 fields")
        if trigger_type == "at":
            _required_string(trigger.get("at"), "at trigger timestamp", maximum=100)
        if trigger_type == "event":
            _required_string(
                trigger.get("event_type"), "event trigger type", maximum=200
            )
        normalized_triggers.append(trigger)
    payload["triggers"] = normalized_triggers
    payload["max_concurrency"] = _positive_bounded_integer(
        payload.get("max_concurrency"),
        "max_concurrency",
        minimum=1,
        maximum=100,
    )
    if payload.get("conflict_policy") not in CONFLICT_POLICIES:
        raise ValueError("unsupported conflict_policy")
    payload["timeout_seconds"] = _positive_bounded_integer(
        payload.get("timeout_seconds"),
        "timeout_seconds",
        minimum=1,
        maximum=86400,
    )
    retry = _closed_object(
        payload.get("retry"), {"max_attempts", "delay_seconds"}, "retry"
    )
    retry["max_attempts"] = _positive_bounded_integer(
        retry.get("max_attempts"), "retry max_attempts", minimum=1, maximum=10
    )
    retry["delay_seconds"] = _positive_bounded_integer(
        retry.get("delay_seconds"),
        "retry delay_seconds",
        minimum=0,
        maximum=86400,
    )
    backfill = _closed_object(
        payload.get("backfill"), {"max_days", "max_runs"}, "backfill"
    )
    backfill["max_days"] = _positive_bounded_integer(
        backfill.get("max_days"), "backfill max_days", minimum=1, maximum=90
    )
    backfill["max_runs"] = _positive_bounded_integer(
        backfill.get("max_runs"), "backfill max_runs", minimum=1, maximum=1000
    )
    _reject_secret_material(payload)
    return payload
