from __future__ import annotations

import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, Union

import yaml


# Make sure the repository root is importable so we can import
# `sync_state_machine.common.types` when running as a script.
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))


class ValidationError(Exception):
    pass


@dataclass(frozen=True)
class Problem:
    level: str  # ERROR | WARN
    message: str
    path: str


def _load_yaml(path: Path) -> Dict[str, Any]:
    try:
        with path.open("r", encoding="utf-8") as f:
            data = yaml.safe_load(f)
    except Exception as exc:
        raise ValidationError(f"Failed to read YAML: {path}: {exc}") from exc

    if not isinstance(data, dict):
        raise ValidationError(f"Top-level YAML must be a mapping/dict: {path}")
    return data


def _as_list(x: Any) -> List[Any]:
    if x is None:
        return []
    if isinstance(x, list):
        return x
    return [x]


def _require_keys(obj: Mapping[str, Any], keys: Sequence[str], base_path: str, problems: List[Problem]) -> None:
    for k in keys:
        if k not in obj:
            problems.append(Problem("ERROR", f"Missing required key '{k}'", f"{base_path}.{k}"))


def _import_enums() -> Tuple[Set[str], Set[str], Set[str]]:
    # Only validate enum NAMES; values (lowercase strings) are implementation details.
    from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus

    binding = {e.name for e in BindingStatus}
    action = {e.name for e in SyncAction}
    status = {e.name for e in SyncStatus}
    return binding, action, status


def _validate_state_def(
    state_id: str,
    state_def: Mapping[str, Any],
    binding_names: Set[str],
    action_names: Set[str],
    status_names: Set[str],
    problems: List[Problem],
) -> None:
    base = f"states.{state_id}"
    _require_keys(state_def, ["binding_status", "action", "status", "desc"], base, problems)

    def _validate_enum_or_list(
        value: Any,
        *,
        allowed: Set[str],
        field_path: str,
        label: str,
    ) -> None:
        if isinstance(value, str):
            if value != "*" and value not in allowed:
                problems.append(Problem("ERROR", f"Unknown {label} name: {value}", field_path))
            return
        if isinstance(value, list):
            if not value:
                problems.append(Problem("ERROR", f"{label} list must be non-empty", field_path))
                return
            for i, item in enumerate(value):
                if not isinstance(item, str):
                    problems.append(Problem("ERROR", f"{label} list items must be strings", f"{field_path}[{i}]"))
                    continue
                if item != "*" and item not in allowed:
                    problems.append(Problem("ERROR", f"Unknown {label} name: {item}", f"{field_path}[{i}]"))
            return
        problems.append(Problem("ERROR", f"{label} must be a string, '*' or a list of strings", field_path))

    _validate_enum_or_list(
        state_def.get("binding_status"),
        allowed=binding_names,
        field_path=f"{base}.binding_status",
        label="BindingStatus",
    )
    _validate_enum_or_list(
        state_def.get("action"),
        allowed=action_names,
        field_path=f"{base}.action",
        label="SyncAction",
    )
    _validate_enum_or_list(
        state_def.get("status"),
        allowed=status_names,
        field_path=f"{base}.status",
        label="SyncStatus",
    )

    # Optional: whether the node has data_id at this state.
    # Used for signature uniqueness checks; kept optional to avoid forcing all configs to specify it.
    data_id_exist = state_def.get("data_id_exist", None)
    if data_id_exist is not None:
        field_path = f"{base}.data_id_exist"
        if data_id_exist == "*":
            pass
        elif isinstance(data_id_exist, bool):
            pass
        elif isinstance(data_id_exist, list):
            if not data_id_exist:
                problems.append(Problem("ERROR", "data_id_exist list must be non-empty", field_path))
            else:
                for i, item in enumerate(data_id_exist):
                    if item == "*":
                        continue
                    if not isinstance(item, bool):
                        problems.append(
                            Problem(
                                "ERROR",
                                "data_id_exist list items must be bool or '*'",
                                f"{field_path}[{i}]",
                            )
                        )
        else:
            problems.append(Problem("ERROR", "data_id_exist must be bool, '*', or a list of them", field_path))


def _expand_enum_values(value: Any, *, all_values: Set[str]) -> Set[str]:
    if isinstance(value, str):
        return set(all_values) if value == "*" else {value}
    if isinstance(value, list):
        out: Set[str] = set()
        for item in value:
            if not isinstance(item, str):
                continue
            if item == "*":
                out |= set(all_values)
            else:
                out.add(item)
        return out
    return set()


def _expand_data_id_exist(value: Any) -> Set[bool]:
    # Missing is treated as wildcard for uniqueness check (strict).
    if value is None or value == "*":
        return {True, False}
    if isinstance(value, bool):
        return {value}
    if isinstance(value, list):
        out: Set[bool] = set()
        for item in value:
            if item == "*":
                out |= {True, False}
            elif isinstance(item, bool):
                out.add(item)
        return out if out else {True, False}
    return {True, False}


def _validate_state_signature_uniqueness(
    states: Mapping[str, Any],
    *,
    binding_names: Set[str],
    action_names: Set[str],
    status_names: Set[str],
    problems: List[Problem],
) -> None:
    """Ensure no two states can share the same (binding_status, action, status, data_id_exist).

    Semantics:
    - '*' expands to all enum values.
    - list expands to the set.
    - data_id_exist missing expands to {True, False} (strict), so uniqueness failures surface.
    """

    sig_to_states: Dict[Tuple[str, str, str, bool], List[str]] = {}

    for sid, sdef in states.items():
        if not isinstance(sdef, dict):
            continue

        binding_set = _expand_enum_values(sdef.get("binding_status"), all_values=binding_names)
        action_set = _expand_enum_values(sdef.get("action"), all_values=action_names)
        status_set = _expand_enum_values(sdef.get("status"), all_values=status_names)
        data_id_set = _expand_data_id_exist(sdef.get("data_id_exist"))

        # If any of the sets are empty due to prior validation errors, skip to avoid noise.
        if not binding_set or not action_set or not status_set:
            continue

        for b in binding_set:
            for a in action_set:
                for st in status_set:
                    for de in data_id_set:
                        sig_to_states.setdefault((b, a, st, de), []).append(str(sid))

    collisions = [(sig, sids) for sig, sids in sig_to_states.items() if len(set(sids)) > 1]
    collisions.sort(key=lambda x: (-len(set(x[1])), x[0]))

    for sig, sids in collisions:
        uniq = sorted(set(sids))
        problems.append(
            Problem(
                "ERROR",
                f"State signature overlaps for (binding_status, action, status, data_id_exist)={sig}: {', '.join(uniq)}",
                "states",
            )
        )


def _collect_state_ids(cfg: Mapping[str, Any], problems: List[Problem]) -> Set[str]:
    states = cfg.get("states")
    if not isinstance(states, dict):
        problems.append(Problem("ERROR", "'states' must be a mapping", "states"))
        return set()
    return set(states.keys())


def _validate_context(cfg: Mapping[str, Any], problems: List[Problem]) -> Set[str]:
    ctx = cfg.get("context")
    if not isinstance(ctx, dict):
        problems.append(Problem("ERROR", "'context' must be a mapping", "context"))
        return set()

    ctx_keys: Set[str] = set()
    for k, v in ctx.items():
        ctx_keys.add(k)
        if not isinstance(v, dict):
            problems.append(Problem("ERROR", "context entry must be a mapping", f"context.{k}"))
            continue
        if "type" not in v:
            problems.append(Problem("ERROR", "context entry missing 'type'", f"context.{k}.type"))
        if "desc" not in v:
            problems.append(Problem("WARN", "context entry missing 'desc'", f"context.{k}.desc"))
        if v.get("type") == "enum":
            vals = v.get("values")
            if not isinstance(vals, list) or not vals:
                problems.append(Problem("ERROR", "enum context must have non-empty 'values'", f"context.{k}.values"))
    return ctx_keys


def _validate_stage(cfg: Mapping[str, Any], stage_id: str, stage_def: Mapping[str, Any], state_ids: Set[str], problems: List[Problem]) -> None:
    base = f"stages.{stage_id}"
    _require_keys(stage_def, ["desc", "entry_states", "exit_states"], base, problems)

    for key in ("entry_states", "exit_states"):
        vals = stage_def.get(key)
        if not isinstance(vals, list) or not vals:
            problems.append(Problem("ERROR", f"{key} must be a non-empty list", f"{base}.{key}"))
            continue
        for s in vals:
            if s == "*":
                continue
            if s not in state_ids and s not in (cfg.get("pseudo_states") or {}):
                problems.append(Problem("ERROR", f"Unknown state id '{s}'", f"{base}.{key}"))


def _validate_stages(cfg: Mapping[str, Any], state_ids: Set[str], problems: List[Problem]) -> Set[str]:
    stages = cfg.get("stages")
    if not isinstance(stages, dict):
        problems.append(Problem("ERROR", "'stages' must be a mapping", "stages"))
        return set()

    stage_ids: Set[str] = set()
    for sid, sdef in stages.items():
        stage_ids.add(sid)
        if not isinstance(sdef, dict):
            problems.append(Problem("ERROR", "stage definition must be a mapping", f"stages.{sid}"))
            continue
        _validate_stage(cfg, sid, sdef, state_ids, problems)
    return stage_ids


def _guard_keys(guard: Mapping[str, Any]) -> Set[str]:
    keys: Set[str] = set()
    for k, v in guard.items():
        if isinstance(v, dict):
            # Nested structures are not allowed for now.
            continue
        keys.add(k)
    return keys


def _validate_transition(
    tid: str,
    tdef: Mapping[str, Any],
    stage_ids: Set[str],
    state_ids: Set[str],
    pseudo_state_ids: Set[str],
    context_keys: Set[str],
    problems: List[Problem],
) -> None:
    base = f"transitions.{tid}"
    _require_keys(tdef, ["stage", "desc", "from", "outcomes"], base, problems)

    stage = tdef.get("stage")
    if isinstance(stage, str) and stage not in stage_ids:
        problems.append(Problem("ERROR", f"Unknown stage '{stage}'", f"{base}.stage"))

    frm = tdef.get("from")
    if not isinstance(frm, list) or not frm:
        problems.append(Problem("ERROR", "'from' must be a non-empty list", f"{base}.from"))
    else:
        for s in frm:
            if s == "*":
                continue
            if s not in state_ids and s not in pseudo_state_ids:
                problems.append(Problem("ERROR", f"Unknown from-state '{s}'", f"{base}.from"))

    outcomes = tdef.get("outcomes")
    if not isinstance(outcomes, list) or not outcomes:
        problems.append(Problem("ERROR", "'outcomes' must be a non-empty list", f"{base}.outcomes"))
        return

    # Basic overlap check for outcome guards.
    normalized_guards: List[Tuple[int, Dict[str, Any]]] = []

    for i, o in enumerate(outcomes):
        opath = f"{base}.outcomes[{i}]"
        if not isinstance(o, dict):
            problems.append(Problem("ERROR", "outcome must be a mapping", opath))
            continue
        if "when" not in o:
            problems.append(Problem("ERROR", "outcome missing 'when'", f"{opath}.when"))
            continue
        when = o.get("when")
        if not isinstance(when, dict):
            problems.append(Problem("ERROR", "'when' must be a mapping", f"{opath}.when"))
            continue

        # Guard variable key validation
        for k in _guard_keys(when):
            if k not in context_keys and k not in ("self.status",):
                problems.append(Problem("ERROR", f"Unknown context key '{k}'", f"{opath}.when.{k}"))

        has_to = "to" in o
        has_to_map = "to_map" in o
        if not (has_to or has_to_map):
            problems.append(Problem("ERROR", "outcome must have 'to' or 'to_map'", opath))
            continue

        if has_to:
            to = o.get("to")
            if not isinstance(to, str):
                problems.append(Problem("ERROR", "'to' must be a state id string", f"{opath}.to"))
            else:
                if to not in state_ids and to not in pseudo_state_ids:
                    problems.append(Problem("ERROR", f"Unknown to-state '{to}'", f"{opath}.to"))

        if has_to_map:
            tm = o.get("to_map")
            if not isinstance(tm, dict):
                problems.append(Problem("ERROR", "'to_map' must be a mapping", f"{opath}.to_map"))
            else:
                if tm.get("key") != "self.status":
                    problems.append(Problem("WARN", "Only to_map.key=self.status is supported by validator", f"{opath}.to_map.key"))
                mp = tm.get("map")
                if not isinstance(mp, dict) or not mp:
                    problems.append(Problem("ERROR", "to_map.map must be a non-empty mapping", f"{opath}.to_map.map"))
                else:
                    for k, v in mp.items():
                        if not isinstance(k, str):
                            problems.append(Problem("ERROR", "to_map.map keys must be strings", f"{opath}.to_map.map"))
                        if not isinstance(v, str) or v not in state_ids:
                            problems.append(Problem("ERROR", f"Unknown to_map target '{v}'", f"{opath}.to_map.map.{k}"))

        emit = o.get("emit")
        if emit is not None:
            if not isinstance(emit, dict):
                problems.append(Problem("ERROR", "emit must be a mapping", f"{opath}.emit"))
            else:
                if "spawn_target_node_state" in emit:
                    spawn = emit.get("spawn_target_node_state")
                    if spawn not in state_ids:
                        problems.append(Problem("ERROR", f"Unknown spawn_target_node_state '{spawn}'", f"{opath}.emit.spawn_target_node_state"))

        normalized_guards.append((i, dict(when)))

    # Overlap check: if two guards are compatible (no contradictory assignments), they overlap.
    for i in range(len(normalized_guards)):
        idx_a, ga = normalized_guards[i]
        for j in range(i + 1, len(normalized_guards)):
            idx_b, gb = normalized_guards[j]
            if _guards_overlap(ga, gb):
                problems.append(
                    Problem(
                        "WARN",
                        f"Outcome guards may overlap (order-dependent): outcomes[{idx_a}] vs outcomes[{idx_b}]",
                        f"{base}.outcomes",
                    )
                )


def _guards_overlap(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
    for k, va in a.items():
        if k not in b:
            continue
        vb = b[k]
        if va != vb:
            return False
    for k, vb in b.items():
        if k not in a:
            continue
        va = a[k]
        if va != vb:
            return False
    return True


def validate_config(cfg_path: Path) -> Tuple[List[Problem], Dict[str, Any]]:
    cfg = _load_yaml(cfg_path)

    problems: List[Problem] = []

    binding_names, action_names, status_names = _import_enums()

    # meta
    meta = cfg.get("meta")
    if not isinstance(meta, dict):
        problems.append(Problem("ERROR", "'meta' must be a mapping", "meta"))

    # context
    context_keys = _validate_context(cfg, problems)

    # states
    state_ids = _collect_state_ids(cfg, problems)
    states = cfg.get("states") if isinstance(cfg.get("states"), dict) else {}
    for sid, sdef in states.items():
        if not isinstance(sdef, dict):
            problems.append(Problem("ERROR", "state definition must be a mapping", f"states.{sid}"))
            continue
        _validate_state_def(sid, sdef, binding_names, action_names, status_names, problems)

    # Ensure states are uniquely identifiable by a strict field signature.
    _validate_state_signature_uniqueness(
        states,
        binding_names=binding_names,
        action_names=action_names,
        status_names=status_names,
        problems=problems,
    )

    pseudo_states = cfg.get("pseudo_states")
    pseudo_state_ids: Set[str] = set()
    if pseudo_states is not None:
        if not isinstance(pseudo_states, dict):
            problems.append(Problem("ERROR", "'pseudo_states' must be a mapping", "pseudo_states"))
        else:
            pseudo_state_ids = set(pseudo_states.keys())

    # stages
    stage_ids = _validate_stages(cfg, state_ids, problems)

    # transitions
    transitions = cfg.get("transitions")
    if not isinstance(transitions, dict):
        problems.append(Problem("ERROR", "'transitions' must be a mapping", "transitions"))
    else:
        for tid, tdef in transitions.items():
            if not isinstance(tdef, dict):
                problems.append(Problem("ERROR", "transition definition must be a mapping", f"transitions.{tid}"))
                continue
            _validate_transition(tid, tdef, stage_ids, state_ids, pseudo_state_ids, context_keys, problems)

    # invariants: basic structural checks only (semantics validated by runtime engine)
    inv = cfg.get("invariants")
    if inv is not None and not isinstance(inv, dict):
        problems.append(Problem("ERROR", "'invariants' must be a mapping", "invariants"))

    return problems, cfg


def main(argv: Optional[Sequence[str]] = None) -> int:
    parser = argparse.ArgumentParser(description="Validate state machine YAML config")
    parser.add_argument(
        "--config",
        default=str(Path(__file__).resolve().parents[1] / "sync_state_machine" / "config" / "node_state_machine.yaml"),
        help="Path to YAML config",
    )
    args = parser.parse_args(list(argv) if argv is not None else None)

    cfg_path = Path(args.config)
    problems, _ = validate_config(cfg_path)

    errors = [p for p in problems if p.level == "ERROR"]
    warns = [p for p in problems if p.level == "WARN"]

    for p in problems:
        print(f"[{p.level}] {p.path}: {p.message}")

    print("\nSummary:")
    print(f"  errors: {len(errors)}")
    print(f"  warns : {len(warns)}")

    if errors:
        return 2
    if warns:
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
