from __future__ import annotations

import copy
import hashlib
import json
import re
from dataclasses import dataclass
from enum import StrEnum
from typing import Any

from app.core.orchestration.spec import (
    validate_schedule_plan,
    validate_workflow_spec,
)


class DualRunMode(StrEnum):
    N8N_PRIMARY = "n8n_primary"
    N8N_PRIMARY_KESTRA_SHADOW = "n8n_primary_kestra_shadow"
    KESTRA_PRIMARY_N8N_STANDBY = "kestra_primary_n8n_standby"
    KESTRA_PRIMARY = "kestra_primary"


_TRANSITIONS = {
    DualRunMode.N8N_PRIMARY: {
        DualRunMode.N8N_PRIMARY_KESTRA_SHADOW,
    },
    DualRunMode.N8N_PRIMARY_KESTRA_SHADOW: {
        DualRunMode.N8N_PRIMARY,
        DualRunMode.KESTRA_PRIMARY_N8N_STANDBY,
    },
    DualRunMode.KESTRA_PRIMARY_N8N_STANDBY: {
        DualRunMode.N8N_PRIMARY_KESTRA_SHADOW,
        DualRunMode.KESTRA_PRIMARY,
    },
    DualRunMode.KESTRA_PRIMARY: {
        DualRunMode.KESTRA_PRIMARY_N8N_STANDBY,
    },
}


def validate_mode_transition(current, target) -> bool:
    try:
        source = DualRunMode(current)
        destination = DualRunMode(target)
    except ValueError as exc:
        raise ValueError("unsupported migration mode") from exc
    if destination not in _TRANSITIONS[source]:
        raise ValueError(
            f"unsupported migration transition: {source.value} -> "
            f"{destination.value}"
        )
    return True


def _canonical(value: Any) -> str:
    return json.dumps(
        value,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )


def _hash(value: Any) -> str:
    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()


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 _node_id(name: str, used: set[str]) -> str:
    candidate = re.sub(r"[^a-z0-9]+", "_", name.strip().lower()).strip("_")
    if not candidate or not candidate[0].isalpha():
        candidate = f"node_{candidate}" if candidate else "node"
    candidate = candidate[:90]
    unique = candidate
    suffix = 2
    while unique in used:
        unique = f"{candidate}_{suffix}"
        suffix += 1
    used.add(unique)
    return unique


class MigrationBlocked(ValueError):
    def __init__(self, report):
        super().__init__("n8n workflow migration is blocked")
        self.report = report


@dataclass(frozen=True)
class ConvertedWorkflow:
    workflow_spec: dict[str, Any]
    schedule_plan: dict[str, Any]
    definition_hash: str
    source_workflow_id: str


class N8nWorkflowConverter:
    _TRIGGER_TYPES = {
        "n8n-nodes-base.manualTrigger": "manual",
        "n8n-nodes-base.cron": "cron",
        "n8n-nodes-base.scheduleTrigger": "cron",
    }
    _SUPPORTED_TASK_TYPES = {
        "n8n-nodes-base.postgres",
        "n8n-nodes-base.httpRequest",
        "n8n-nodes-base.if",
    }

    @staticmethod
    def _trigger(node):
        kind = N8nWorkflowConverter._TRIGGER_TYPES[node["type"]]
        if kind == "manual":
            return {"type": "manual"}
        expression = (
            dict(node.get("parameters") or {}).get("cronExpression")
            or dict(node.get("parameters") or {}).get("expression")
        )
        if not isinstance(expression, str) or len(expression.split()) not in {
            5,
            6,
            7,
        }:
            raise ValueError("cron trigger requires an explicit cron expression")
        return {"type": "cron", "expression": expression}

    @staticmethod
    def _postgres_node(node, node_id, data_source_uid):
        parameters = dict(node.get("parameters") or {})
        operation = parameters.get("operation")
        if operation != "executeQuery":
            raise ValueError("postgres operation is not safely convertible")
        statement = _required_string(parameters.get("query"), "postgres query", 20000)
        purpose = parameters.get("purpose")
        if purpose != "read" or not re.match(r"(?is)^\s*select\b", statement):
            raise ValueError(
                "automatic n8n conversion supports only explicit read queries"
            )
        return {
            "id": node_id,
            "type": "sql.query",
            "data_source_uid": data_source_uid,
            "purpose": "read",
            "config": {"statement": statement, "parameters": {}},
        }

    @staticmethod
    def _http_node(node, node_id):
        parameters = dict(node.get("parameters") or {})
        method = str(parameters.get("method") or "GET").upper()
        if method != "GET":
            raise ValueError("automatic HTTP conversion supports only GET")
        return {
            "id": node_id,
            "type": "http",
            "purpose": "read",
            "config": {
                "method": "GET",
                "url": _required_string(parameters.get("url"), "HTTP URL", 2000),
            },
        }

    @staticmethod
    def _condition_node(node, node_id):
        parameters = dict(node.get("parameters") or {})
        return {
            "id": node_id,
            "type": "condition",
            "config": {"conditions": copy.deepcopy(parameters.get("conditions") or {})},
        }

    def convert(self, workflow, *, dataflow_uid, data_source_uids):
        if not isinstance(workflow, dict):
            raise ValueError("n8n workflow must be an object")
        workflow_id = _required_string(workflow.get("id"), "n8n workflow id", 255)
        workflow_name = _required_string(
            workflow.get("name"), "n8n workflow name", 200
        )
        nodes = workflow.get("nodes")
        if not isinstance(nodes, list) or not nodes:
            raise ValueError("n8n workflow nodes must be a non-empty array")
        if not isinstance(data_source_uids, dict):
            raise ValueError("data source mapping must be an object")

        blockers = []
        triggers = []
        used_ids: set[str] = set()
        task_by_name = {}
        task_nodes = []
        trigger_names = set()
        for node in nodes:
            name = _required_string(node.get("name"), "n8n node name", 200)
            node_type = _required_string(node.get("type"), "n8n node type", 300)
            if node_type in self._TRIGGER_TYPES:
                trigger_names.add(name)
                try:
                    triggers.append(self._trigger(node))
                except ValueError:
                    blockers.append(
                        {
                            "node_name": name,
                            "node_type": node_type,
                            "reason_code": "unsupported_trigger_configuration",
                        }
                    )
                continue
            if node_type not in self._SUPPORTED_TASK_TYPES:
                blockers.append(
                    {
                        "node_name": name,
                        "node_type": node_type,
                        "reason_code": "unsupported_node_type",
                    }
                )
                continue
            node_id = _node_id(name, used_ids)
            try:
                if node_type == "n8n-nodes-base.postgres":
                    source_uid = data_source_uids.get(name)
                    if not source_uid:
                        raise ValueError("postgres node data source mapping is required")
                    converted = self._postgres_node(node, node_id, source_uid)
                elif node_type == "n8n-nodes-base.httpRequest":
                    converted = self._http_node(node, node_id)
                else:
                    converted = self._condition_node(node, node_id)
            except ValueError:
                blockers.append(
                    {
                        "node_name": name,
                        "node_type": node_type,
                        "reason_code": "unsupported_node_configuration",
                    }
                )
                continue
            task_by_name[name] = node_id
            task_nodes.append(converted)

        if blockers:
            raise MigrationBlocked(
                {
                    "status": "blocked",
                    "workflow_id": workflow_id,
                    "workflow_name": workflow_name,
                    "blockers": blockers,
                }
            )
        if not task_nodes:
            raise ValueError("n8n workflow has no convertible task nodes")
        if not triggers:
            triggers = [{"type": "manual"}]

        edges = []
        connections = workflow.get("connections") or {}
        if not isinstance(connections, dict):
            raise ValueError("n8n workflow connections must be an object")
        for source_name, outputs in connections.items():
            if source_name in trigger_names:
                continue
            source_id = task_by_name.get(source_name)
            if source_id is None:
                continue
            for group in dict(outputs or {}).values():
                for branch in group or []:
                    for target in branch or []:
                        target_id = task_by_name.get(target.get("node"))
                        if target_id:
                            edges.append({"from": source_id, "to": target_id})

        spec = validate_workflow_spec(
            {
                "schema_version": "1.0",
                "dataflow_uid": dataflow_uid,
                "name": workflow_name,
                "nodes": task_nodes,
                "edges": edges,
                "parameters": {},
                "labels": {
                    "migration_source": "n8n",
                    "n8n_workflow_id": workflow_id,
                },
            }
        )
        plan = validate_schedule_plan(
            {
                "schema_version": "1.0",
                "timezone": "Asia/Shanghai",
                "triggers": triggers,
                "max_concurrency": 1,
                "conflict_policy": "skip",
                "timeout_seconds": 3600,
                "retry": {"max_attempts": 1, "delay_seconds": 0},
                "backfill": {"max_days": 1, "max_runs": 1},
            }
        )
        return ConvertedWorkflow(
            workflow_spec=spec,
            schedule_plan=plan,
            definition_hash=_hash({"workflow_spec": spec, "schedule_plan": plan}),
            source_workflow_id=workflow_id,
        )


@dataclass(frozen=True)
class ShadowIsolation:
    strategy: str
    target_prefix: str | None = None

    @classmethod
    def read_only(cls):
        return cls("read_only")

    @classmethod
    def isolated_target(cls, target_prefix):
        prefix = _required_string(target_prefix, "shadow target prefix", 500)
        return cls("isolated_target", prefix)

    def validate(self, workflow_spec):
        workflow = validate_workflow_spec(workflow_spec)
        writes = [
            node
            for node in workflow["nodes"]
            if node.get("purpose") == "write" or node["type"] == "sql.execute"
        ]
        if not writes:
            return {"strategy": "read_only", "write_nodes": 0}
        if self.strategy != "isolated_target" or not self.target_prefix:
            raise ValueError("write shadow requires an isolated shadow target")
        for node in writes:
            target = node["config"].get("shadow_target")
            if (
                not isinstance(target, str)
                or not target.startswith(self.target_prefix)
                or target == self.target_prefix
            ):
                raise ValueError("write shadow requires an isolated shadow target")
        return {
            "strategy": "isolated_target",
            "target_prefix": self.target_prefix,
            "write_nodes": len(writes),
        }


class DualRunCoordinator:
    def __init__(self, *, n8n_engine, kestra_engine, store):
        self.n8n_engine = n8n_engine
        self.kestra_engine = kestra_engine
        self.store = store

    def execute_shadow(
        self,
        *,
        dataflow_uid,
        environment,
        mode,
        n8n_definition_id,
        kestra_definition_id,
        workflow_spec,
        inputs,
        correlation_id,
        isolation,
    ):
        if DualRunMode(mode) is not DualRunMode.N8N_PRIMARY_KESTRA_SHADOW:
            raise ValueError("shadow execution requires n8n primary Kestra shadow mode")
        if environment not in {"development", "test", "production"}:
            raise ValueError("unsupported environment")
        if not isinstance(inputs, dict):
            raise ValueError("dual-run inputs must be an object")
        isolation_result = isolation.validate(workflow_spec)
        snapshot = copy.deepcopy(inputs)
        input_hash = _hash(snapshot)
        primary = self.n8n_engine.execute(n8n_definition_id, copy.deepcopy(snapshot))
        shadow = self.kestra_engine.execute(
            kestra_definition_id,
            copy.deepcopy(snapshot),
        )
        record = {
            "dataflow_uid": dataflow_uid,
            "environment": environment,
            "mode": DualRunMode(mode).value,
            "primary_engine": "n8n",
            "shadow_engine": "kestra",
            "primary_execution_id": primary.get("execution_id"),
            "shadow_execution_id": shadow.get("execution_id"),
            "primary_status": primary.get("status"),
            "shadow_status": shadow.get("status"),
            "input_snapshot_hash": input_hash,
            "isolation": isolation_result,
            "correlation_id": correlation_id,
            "formal_engine_changed": False,
        }
        return self.store.record_dual_run(record)
