from __future__ import annotations

import json
import uuid
from pathlib import Path
from typing import Any

import yaml

from auto_test_lib import (
    copy_selected_files,
    copy_tree_contents,
    diff_record_fields,
    enrich_supplier_display_fields,
    latest_log_file,
    merge_tree_contents,
    normalize_contract_settlement_records,
    normalize_construction_task_plan_nodes,
    persistence_summary,
    read_jsonl_records,
    load_yaml_config,
    run_command,
    sqlite_rows,
)


def _tail_text(value: str | None, max_chars: int = 1000) -> str:
    if not value:
        return ""
    if len(value) <= max_chars:
        return value
    return value[-max_chars:]


def collect_required_bindings(db_path: str, required_bindings: dict[str, list[str]]) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for node_type, local_ids in required_bindings.items():
        result[node_type] = []
        local_nodes = sqlite_rows(
            db_path,
            "SELECT node_id, data_id FROM nodes WHERE collection_id = 'local' AND node_type = ?",
            (node_type,),
        )
        remote_nodes = sqlite_rows(
            db_path,
            "SELECT node_id, data_id FROM nodes WHERE collection_id = 'remote' AND node_type = ?",
            (node_type,),
        )
        bindings = sqlite_rows(
            db_path,
            "SELECT local_id, remote_id FROM bindings WHERE node_type = ?",
            (node_type,),
        )
        local_data_to_node = {item["data_id"]: item["node_id"] for item in local_nodes}
        remote_node_to_data = {item["node_id"]: item["data_id"] for item in remote_nodes}
        bound_map = {item["local_id"]: item.get("remote_id") for item in bindings}
        for local_id in local_ids:
            local_node_id = local_data_to_node.get(local_id)
            remote_node_id = bound_map.get(local_node_id) if local_node_id else None
            result[node_type].append(
                {
                    "local_data_id": local_id,
                    "local_node_id": local_node_id,
                    "remote_node_id": remote_node_id,
                    "remote_data_id": remote_node_to_data.get(remote_node_id) if remote_node_id else None,
                    "bound": remote_node_id is not None,
                }
            )
    return result


def collect_problem_nodes(db_path: str, node_types: list[str] | None = None) -> list[dict[str, Any]]:
    problems: list[dict[str, Any]] = []
    seen: set[tuple[str | None, str | None]] = set()

    sql = """
        SELECT
            collection_id,
            node_type,
            node_id,
            data_id,
            bind_data_id,
            action,
            status,
            binding_status,
            error,
            sync_log,
            context
        FROM nodes
    """
    params: tuple[Any, ...] = ()
    if node_types:
        placeholders = ", ".join("?" for _ in node_types)
        sql += f" WHERE node_type IN ({placeholders})"
        params = tuple(node_types)
    sql += " ORDER BY node_type, collection_id, node_id"

    rows = sqlite_rows(db_path, sql, params)
    for row in rows:
        status = row.get("status")
        binding_status = row.get("binding_status")
        error = row.get("error")
        has_problem = bool(error) or status == "FAILED" or (binding_status not in (None, "", "normal"))
        if not has_problem:
            continue

        key = (row.get("collection_id"), row.get("node_id"))
        if key in seen:
            continue
        seen.add(key)

        problems.append(
            {
                "collection_id": row.get("collection_id"),
                "node_type": row.get("node_type"),
                "node_id": row.get("node_id"),
                "data_id": row.get("data_id"),
                "bind_data_id": row.get("bind_data_id"),
                "action": row.get("action"),
                "status": status,
                "binding_status": binding_status,
                "error": error,
                "sync_log_tail": _tail_text(row.get("sync_log"), 1600),
                "context": row.get("context"),
            }
        )

    return problems


def collect_round_diffs(previous_dataset_dir: str | None, current_dataset_dir: str) -> dict[str, dict[str, list[str]]]:
    if not previous_dataset_dir:
        return {}

    previous_root = Path(previous_dataset_dir)
    current_root = Path(current_dataset_dir)
    diffs: dict[str, dict[str, list[str]]] = {}

    for after_file in sorted(current_root.glob("*.jsonl")):
        before_file = previous_root / after_file.name
        if not before_file.exists():
            continue

        node_type = _infer_node_type_from_jsonl(after_file)
        before_records = read_jsonl_records(before_file)
        after_records = read_jsonl_records(after_file)
        file_diff: dict[str, list[str]] = {}
        for record_id, after in after_records.items():
            before = before_records.get(record_id)
            if not before:
                continue
            changed = diff_record_fields(before, after)
            if changed:
                file_diff[record_id] = changed
        if file_diff:
            diffs[node_type] = file_diff

    return diffs


def collect_round_diff(previous_dataset_dir: str | None, current_dataset_dir: str, domain_name: str) -> dict[str, list[str]]:
    return collect_round_diffs(previous_dataset_dir, current_dataset_dir).get(domain_name, {})


def _resolve_field_path(data: Any, field_path: str) -> Any:
    current = data
    for part in field_path.split("."):
        if isinstance(current, dict):
            current = current.get(part)
            continue
        if isinstance(current, list) and part.isdigit():
            index = int(part)
            if 0 <= index < len(current):
                current = current[index]
                continue
        return None
    return current


def collect_node_data_by_data_ids(
    db_path: str,
    *,
    collection_id: str,
    node_type: str,
    data_ids: list[str],
) -> dict[str, Any]:
    if not data_ids:
        return {}

    placeholders = ", ".join("?" for _ in data_ids)
    rows = sqlite_rows(
        db_path,
        (
            "SELECT data_id, data FROM nodes "
            "WHERE collection_id = ? AND node_type = ? AND data_id IN ("
            f"{placeholders})"
        ),
        (collection_id, node_type, *data_ids),
    )

    result: dict[str, Any] = {}
    for row in rows:
        raw = row.get("data")
        data_id = row.get("data_id")
        if data_id is None or not raw:
            continue
        try:
            result[str(data_id)] = json.loads(raw)
        except json.JSONDecodeError:
            continue
    return result


def make_local_data_id(node_type: str, remote_data_id: str) -> str:
    return str(uuid.uuid5(uuid.NAMESPACE_URL, f"auto-test-local:{node_type}:{remote_data_id}"))


def _infer_node_type_from_jsonl(file_path: Path) -> str:
    name = file_path.name
    if name.endswith("_1.jsonl"):
        return name[:-8]
    if name.endswith(".jsonl"):
        return name[:-6]
    return file_path.stem


def remap_project_scope_local_ids(
    dataset_root: str | Path,
    *,
    remote_project_id: str,
    local_project_id: str,
    remap_node_types: set[str],
) -> None:
    root = Path(dataset_root)
    for file_path in sorted(root.glob("*.jsonl")):
        node_type = _infer_node_type_from_jsonl(file_path)
        raw_lines = file_path.read_text(encoding="utf-8").splitlines()
        rewritten: list[str] = []
        changed = False
        for line in raw_lines:
            raw = line.strip()
            if not raw:
                continue
            item = json.loads(raw)
            original = json.dumps(item, ensure_ascii=False, sort_keys=True)

            if item.get("project_id") == remote_project_id:
                item["project_id"] = local_project_id

            if node_type == "project" and item.get("_id") == remote_project_id:
                item["_id"] = local_project_id
            elif node_type in remap_node_types and item.get("project_id") == local_project_id and item.get("_id"):
                item["_id"] = make_local_data_id(node_type, str(item["_id"]))

            changed = changed or (json.dumps(item, ensure_ascii=False, sort_keys=True) != original)
            rewritten.append(json.dumps(item, ensure_ascii=False))

        if changed:
            file_path.write_text("\n".join(rewritten) + "\n", encoding="utf-8")


def remap_project_scope_dataset_diffs(
    dataset_diffs: dict[str, dict[str, list[str]]],
    *,
    remote_project_id: str,
    local_project_id: str,
    remap_node_types: set[str],
) -> dict[str, dict[str, list[str]]]:
    remapped: dict[str, dict[str, list[str]]] = {}
    for node_type, record_changes in dataset_diffs.items():
        remapped[node_type] = {}
        for record_id, changed_fields in record_changes.items():
            new_record_id = record_id
            if node_type == "project" and record_id == remote_project_id:
                new_record_id = local_project_id
            elif node_type in remap_node_types:
                new_record_id = make_local_data_id(node_type, record_id)
            remapped[node_type][new_record_id] = changed_fields
    return remapped


def build_temp_run_profile_for_project_auto_bind(
    *,
    config: dict[str, Any],
    report_root: str | Path,
    profile_name: str,
    local_project_id: str,
    remote_project_id: str,
    project_update_direction: str | None = None,
    project_local_orphan_action: str | None = None,
    project_remote_orphan_action: str | None = None,
    project_detail_push_keys: set[str] | None = None,
) -> tuple[str, list[str]]:
    profile = load_yaml_config(config["pipeline"]["run_profile"])
    profile.setdefault("local_datasource", {}).setdefault("handler_configs", {}).setdefault("project", {})["data_id_filter"] = [local_project_id]
    profile.setdefault("remote_datasource", {}).setdefault("handler_configs", {}).setdefault("project", {})["data_id_filter"] = [remote_project_id]

    project_strategy = profile.setdefault("strategies", {}).setdefault("project", {}).setdefault("config", {})
    project_strategy["auto_bind"] = False
    project_strategy["auto_bind_fields"] = []
    project_strategy["pre_bind_data_id"] = [
        {
            "local_data_id": local_project_id,
            "remote_data_id": remote_project_id,
        }
    ]
    project_strategy["local_orphan_action"] = project_local_orphan_action or "none"
    project_strategy["remote_orphan_action"] = project_remote_orphan_action or "none"
    project_strategy["update_direction"] = project_update_direction or "none"

    if project_detail_push_keys:
        project_detail_config = profile.setdefault("strategies", {}).setdefault("project_detail_data", {}).setdefault("config", {})
        overrides = dict(project_detail_config.get("field_direction_overrides", {}))
        for key in project_detail_push_keys:
            overrides.pop(key, None)
        project_detail_config["field_direction_overrides"] = overrides

    runtime_dir = Path(report_root) / "runtime_configs"
    runtime_dir.mkdir(parents=True, exist_ok=True)
    profile_path = runtime_dir / f"{profile_name}.yaml"
    profile_path.write_text(yaml.safe_dump(profile, allow_unicode=True, sort_keys=False), encoding="utf-8")

    command = list(config["pipeline"]["command"])
    if "--config_path" in command:
        index = command.index("--config_path")
        if index + 1 < len(command):
            command[index + 1] = str(profile_path)
        else:
            command.append(str(profile_path))
    else:
        command.extend(["--config_path", str(profile_path)])
    return str(profile_path), command


def run_pipeline_round(
    *,
    config: dict[str, Any],
    dataset_dir: str,
    previous_dataset_dir: str | None,
    base_dataset_dir: str | None = None,
    base_dataset_files: list[str] | None = None,
    domain_name: str,
    round_name: str,
    purpose: str,
    expected_changes: dict[str, Any] | None = None,
    required_bindings: dict[str, list[str]] | None = None,
    expected_binding_pairs: dict[str, dict[str, str]] | None = None,
    require_distinct_binding_ids: dict[str, list[str]] | None = None,
    expected_bound_fields: dict[str, dict[str, dict[str, dict[str, Any]]]] | None = None,
    project_id_remap: dict[str, Any] | None = None,
    project_detail_push_keys: set[str] | None = None,
    report_root: str | Path | None = None,
    coverage: dict[str, Any] | None = None,
) -> dict[str, Any]:
    pipeline = config["pipeline"]
    paths = config["paths"]

    if base_dataset_dir and base_dataset_files:
        copy_selected_files(base_dataset_dir, paths["temp_data_dir"], base_dataset_files)
        merge_tree_contents(dataset_dir, paths["temp_data_dir"])
    else:
        copy_tree_contents(dataset_dir, paths["temp_data_dir"])
    common_dataset_dir = paths.get("common_dataset_dir")
    if common_dataset_dir and Path(common_dataset_dir).exists():
        merge_tree_contents(common_dataset_dir, paths["temp_data_dir"])
    enrich_supplier_display_fields(
        paths["temp_data_dir"],
        f"{config['project_root']}/working_local_datasource/filtered/region_1.jsonl",
    )
    normalize_contract_settlement_records(paths["temp_data_dir"])
    normalize_construction_task_plan_nodes(paths["temp_data_dir"])

    command = pipeline["command"]
    temp_run_profile_path = None
    if project_id_remap:
        local_project_id = str(project_id_remap["local_project_id"])
        remote_project_id = str(project_id_remap["remote_project_id"])
        remap_project_scope_local_ids(
            paths["temp_data_dir"],
            remote_project_id=remote_project_id,
            local_project_id=local_project_id,
            remap_node_types=set(project_id_remap.get("remap_node_types", set())),
        )
        if report_root is None:
            raise ValueError("project_id_remap requires report_root")
        temp_run_profile_path, command = build_temp_run_profile_for_project_auto_bind(
            config=config,
            report_root=report_root,
            profile_name=f"{domain_name}_{round_name}",
            local_project_id=local_project_id,
            remote_project_id=remote_project_id,
            project_update_direction=project_id_remap.get("project_update_direction"),
            project_local_orphan_action=project_id_remap.get("project_local_orphan_action"),
            project_remote_orphan_action=project_id_remap.get("project_remote_orphan_action"),
            project_detail_push_keys=project_detail_push_keys,
        )

    result = run_command(command, pipeline["project_root"])
    latest_log = latest_log_file(paths["pipeline_log_dir"])
    dataset_diff = collect_round_diff(previous_dataset_dir, dataset_dir, domain_name)
    dataset_diffs = collect_round_diffs(previous_dataset_dir, dataset_dir)
    if project_id_remap:
        dataset_diffs = remap_project_scope_dataset_diffs(
            dataset_diffs,
            remote_project_id=str(project_id_remap["remote_project_id"]),
            local_project_id=str(project_id_remap["local_project_id"]),
            remap_node_types=set(project_id_remap.get("remap_node_types", set())),
        )
        dataset_diff = dataset_diffs.get(domain_name, {})
    binding_report = collect_required_bindings(paths["persistence_db"], required_bindings or {})
    persist_report = persistence_summary(paths["persistence_db"], domain_name)
    watched_problem_types = set(required_bindings or {})
    watched_problem_types.update(expected_changes or {})
    for collection_node_types in (expected_bound_fields or {}).values():
        watched_problem_types.update(collection_node_types)
    watched_problem_types.add(domain_name)
    problem_nodes = collect_problem_nodes(
        paths["persistence_db"],
        sorted(watched_problem_types) if watched_problem_types else None,
    )

    issues: list[str] = []
    if result.returncode != 0:
        issues.append(f"pipeline 返回码不是 0: {result.returncode}")

    for node_type, items in binding_report.items():
        for item in items:
            if not item["bound"]:
                issues.append(f"{node_type} 未建立绑定: {item['local_data_id']}")

    expected_binding_pairs = expected_binding_pairs or {}
    for node_type, local_to_remote in expected_binding_pairs.items():
        actual_items = {item["local_data_id"]: item for item in binding_report.get(node_type, [])}
        for local_id, expected_remote_id in local_to_remote.items():
            actual = actual_items.get(local_id)
            if not actual:
                issues.append(f"{node_type} 缺少绑定检查记录: {local_id}")
                continue
            if actual.get("remote_data_id") != expected_remote_id:
                issues.append(
                    f"{node_type} 绑定目标不符合预期: local={local_id} remote={actual.get('remote_data_id')} expected={expected_remote_id}"
                )

    require_distinct_binding_ids = require_distinct_binding_ids or {}
    for node_type, local_ids in require_distinct_binding_ids.items():
        actual_items = {item["local_data_id"]: item for item in binding_report.get(node_type, [])}
        for local_id in local_ids:
            actual = actual_items.get(local_id)
            if not actual or not actual.get("bound"):
                continue
            if actual.get("remote_data_id") == local_id:
                issues.append(f"{node_type} 绑定后 local/remote data_id 不应相同: {local_id}")

    expected_bound_fields = expected_bound_fields or {}
    verified_bound_fields: list[dict[str, Any]] = []
    for collection_id, node_types in expected_bound_fields.items():
        for node_type, local_expectations in node_types.items():
            binding_items = {item["local_data_id"]: item for item in binding_report.get(node_type, [])}
            target_id_by_local: dict[str, str] = {}
            for local_data_id in local_expectations:
                if collection_id == "local":
                    target_id_by_local[local_data_id] = local_data_id
                    continue
                remote_data_id = (binding_items.get(local_data_id) or {}).get("remote_data_id")
                if remote_data_id:
                    target_id_by_local[local_data_id] = remote_data_id

            records = collect_node_data_by_data_ids(
                paths["persistence_db"],
                collection_id=collection_id,
                node_type=node_type,
                data_ids=list(target_id_by_local.values()),
            )

            for local_data_id, expected_fields in local_expectations.items():
                target_data_id = target_id_by_local.get(local_data_id)
                record = records.get(target_data_id) if target_data_id else None
                if not target_data_id or record is None:
                    issues.append(
                        f"{collection_id}.{node_type} 字段校验缺少目标记录: local={local_data_id} resolved={target_data_id}"
                    )
                    continue

                actual_fields: dict[str, Any] = {}
                mismatches: list[str] = []
                for field_path, expected_value in expected_fields.items():
                    actual_value = _resolve_field_path(record, field_path)
                    actual_fields[field_path] = actual_value
                    if actual_value != expected_value:
                        mismatches.append(
                            f"{field_path}: actual={actual_value!r} expected={expected_value!r}"
                        )

                verified_bound_fields.append(
                    {
                        "collection_id": collection_id,
                        "node_type": node_type,
                        "local_data_id": local_data_id,
                        "target_data_id": target_data_id,
                        "matched": not mismatches,
                        "expected_fields": expected_fields,
                        "actual_fields": actual_fields,
                    }
                )
                if mismatches:
                    issues.append(
                        f"{collection_id}.{node_type} 字段校验失败: local={local_data_id} target={target_data_id}; "
                        + "; ".join(mismatches)
                    )

    relevant_local_ids: set[str] = set()
    for local_ids in (required_bindings or {}).values():
        relevant_local_ids.update(local_ids)

    relevant_remote_ids: set[str] = set()
    for items in binding_report.values():
        for item in items:
            remote_data_id = item.get("remote_data_id")
            if remote_data_id:
                relevant_remote_ids.add(str(remote_data_id))
    for remote_map in (expected_binding_pairs or {}).values():
        relevant_remote_ids.update(str(remote_id) for remote_id in remote_map.values() if remote_id)

    matched_verified_local_keys = {
        (entry["node_type"], entry["local_data_id"])
        for entry in verified_bound_fields
        if entry.get("matched")
    }

    for item in problem_nodes:
        item_data_id = item.get("data_id")
        if item.get("collection_id") == "local":
            if item_data_id not in relevant_local_ids:
                continue
        elif item.get("collection_id") == "remote":
            if not item_data_id or item_data_id not in relevant_remote_ids:
                continue

        error_text = item.get("error") or ""
        if item.get("status") == "FAILED":
            issues.append(
                f"{item['collection_id']}.{item['node_type']} 节点失败: data_id={item.get('data_id')} action={item.get('action')} error={error_text or '<empty>'}"
            )
        elif item.get("binding_status") not in (None, "", "normal"):
            if (
                item.get("collection_id") == "local"
                and (item.get("node_type"), item.get("data_id")) in matched_verified_local_keys
            ):
                continue
            issues.append(
                f"{item['collection_id']}.{item['node_type']} 绑定异常: data_id={item.get('data_id')} binding_status={item.get('binding_status')}"
            )
        elif error_text:
            continue

    expected_changes = expected_changes or {}
    for node_type, record_changes in expected_changes.items():
        node_diff = dataset_diffs.get(node_type, {})
        for record_id, expected_fields in record_changes.items():
            actual_changed_fields: list[str] = node_diff.get(record_id, [])
            missing = [field for field in expected_fields if field not in actual_changed_fields]
            if missing:
                issues.append(f"数据变更字段不完整: {record_id} 缺少 {', '.join(missing)}")

    return {
        "name": round_name,
        "purpose": purpose,
        "dataset_dir": dataset_dir,
        "returncode": result.returncode,
        "passed": not issues,
        "issues": issues,
        "stdout_tail": result.stdout[-2000:],
        "stderr_tail": result.stderr[-2000:],
        "latest_log_file": latest_log,
        "expected_changes": expected_changes,
        "dataset_diff": dataset_diff,
        "dataset_diffs": dataset_diffs,
        "required_bindings": binding_report,
        "expected_binding_pairs": expected_binding_pairs,
        "require_distinct_binding_ids": require_distinct_binding_ids,
        "verified_bound_fields": verified_bound_fields,
        "problem_nodes": problem_nodes,
        "persistence_summary": persist_report,
        "temp_run_profile_path": temp_run_profile_path,
        "coverage": coverage or {},
    }


def build_domain_report(domain: str, description: str, rounds: list[dict[str, Any]]) -> dict[str, Any]:
    issues: list[str] = []
    for round_report in rounds:
        for issue in round_report.get("issues", []):
            issues.append(f"{round_report['name']}: {issue}")
    return {
        "domain": domain,
        "description": description,
        "passed": len(issues) == 0,
        "issues": issues,
        "rounds": rounds,
    }