from __future__ import annotations

import json
import re
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import Any

LIB_DIR = Path(__file__).resolve().parent
PROJECT_DIR = LIB_DIR.parent
APP_DIR = PROJECT_DIR / "app"
SETTINGS_DIR = PROJECT_DIR / "settings"
SETTINGS_FILE = SETTINGS_DIR / "settings.json"
VMIX_JSON_FILE = SETTINGS_DIR / "vmix_json.json"
VMIX_FUNCTIONS_FILE = SETTINGS_DIR / "vmix_functions.json"
BACKUPS_DIR = SETTINGS_DIR / "backups"


def configure(
    *,
    project_dir: str | Path | None = None,
    app_dir: str | Path | None = None,
    settings_dir: str | Path | None = None,
    settings_file: str = "settings.json",
    vmix_json_file: str = "vmix_json.json",
    vmix_functions_file: str = "vmix_functions.json",
    default_settings: dict[str, Any] | None = None,
    default_vmix_json: dict[str, Any] | None = None,
    default_vmix_functions: dict[str, Any] | None = None,
) -> None:
    """Configure the library for any project.

    The module keeps the same simple function API, but all paths and defaults can
    be injected by another project. That means the same folder ``broadcast_settings``
    can be copied to Golf, Athletics, Football, etc.
    """
    global PROJECT_DIR, APP_DIR, SETTINGS_DIR, SETTINGS_FILE, VMIX_JSON_FILE, VMIX_FUNCTIONS_FILE, BACKUPS_DIR
    global DEFAULT_SETTINGS, DEFAULT_VMIX_JSON, DEFAULT_VMIX_FUNCTIONS

    if project_dir is not None:
        PROJECT_DIR = Path(project_dir).resolve()
    if app_dir is not None:
        APP_DIR = Path(app_dir).resolve()
    else:
        APP_DIR = PROJECT_DIR / "app"

    if settings_dir is not None:
        SETTINGS_DIR = Path(settings_dir).resolve()
    else:
        SETTINGS_DIR = PROJECT_DIR / "settings"

    SETTINGS_FILE = SETTINGS_DIR / settings_file
    VMIX_JSON_FILE = SETTINGS_DIR / vmix_json_file
    VMIX_FUNCTIONS_FILE = SETTINGS_DIR / vmix_functions_file
    BACKUPS_DIR = SETTINGS_DIR / "backups"

    if default_settings is not None:
        DEFAULT_SETTINGS = deepcopy(default_settings)
    if default_vmix_json is not None:
        DEFAULT_VMIX_JSON = deepcopy(default_vmix_json)
    if default_vmix_functions is not None:
        DEFAULT_VMIX_FUNCTIONS = deepcopy(default_vmix_functions)

DEFAULT_SELECTED_COLUMNS = [
    "position", "player", "player_id", "country", "club",
    "total", "to_par", "today", "thru",
    "hole_1", "hole_2", "hole_3", "hole_4", "hole_5", "hole_6", "hole_7", "hole_8", "hole_9",
    "hole_10", "hole_11", "hole_12", "hole_13", "hole_14", "hole_15", "hole_16", "hole_17", "hole_18",
]

DEFAULT_SETTINGS: dict[str, Any] = {
    "version": 1,
    "ui": {
        "calendar_collapsed": True,
        "default_date": "today",
        "theme": "broadcast_dark",
    },
    "selection": {},
    "scores": {
        "limit": "",
        "search": "",
        "selected_columns": DEFAULT_SELECTED_COLUMNS,
        "active_vmix_json": "leaderboard",
    },
    "api": {
        "base_url": "https://rusgolf.ru/api/livescoring",
        "cache_tournaments_seconds": 30,
        "cache_scores_seconds": 2,
    },
}

DEFAULT_VMIX_JSON: dict[str, Any] = {
    "version": 1,
    "configs": [
        {
            "key": "leaderboard",
            "title": "Leaderboard",
            "description": "Основная таблица лидеров для vMix.",
            "endpoint": "/vmix/golf/leaderboard.json",
            "root_key": "players",
            "default_limit": 0,
            "enabled": True,
            "show_tab": True,
            "tab_title": "Leaderboard",
            "sort_key": "position",
            "sort_dir": "asc",
            "columns": [
                {"key": "position", "label": "Position", "source": "position", "default": "", "enabled": True},
                {"key": "player", "label": "Player", "source": "player", "default": "", "enabled": True},
                {"key": "country", "label": "Country", "source": "country", "default": "", "enabled": True},
                {"key": "club", "label": "Club", "source": "club", "default": "", "enabled": True},
                {"key": "total", "label": "Total", "source": "total", "default": "", "enabled": True},
                {"key": "to_par", "label": "To Par", "source": "to_par", "default": "", "enabled": True},
                {"key": "today", "label": "Today", "source": "today", "default": "", "enabled": True},
                {"key": "thru", "label": "Thru", "source": "thru", "default": "", "enabled": True},
            ],
        },
        {
            "key": "scorecard",
            "title": "Scorecard 1–18",
            "description": "Карточка игрока/строка с лунками 1–18.",
            "endpoint": "/vmix/golf/json/scorecard.json",
            "root_key": "players",
            "default_limit": 0,
            "enabled": True,
            "show_tab": True,
            "tab_title": "Scorecard",
            "sort_key": "position",
            "sort_dir": "asc",
            "columns": [
                {"key": "position", "label": "Position", "source": "position", "default": "", "enabled": True},
                {"key": "player", "label": "Player", "source": "player", "default": "", "enabled": True},
                {"key": "total", "label": "Total", "source": "total", "default": "", "enabled": True},
                {"key": "to_par", "label": "To Par", "source": "to_par", "default": "", "enabled": True},
                *[
                    {"key": f"hole_{i}", "label": f"H{i}", "source": f"hole_{i}", "default": "", "enabled": True}
                    for i in range(1, 19)
                ],
            ],
        },
        {
            "key": "current",
            "title": "Current Selection",
            "description": "Фиксированный JSON по сохранённому соревнованию и раунду.",
            "endpoint": "/vmix/golf/current.json",
            "root_key": "players",
            "default_limit": 0,
            "enabled": True,
            "show_tab": True,
            "tab_title": "Current",
            "sort_key": "position",
            "sort_dir": "asc",
            "columns": [
                {"key": "position", "label": "Position", "source": "position", "default": "", "enabled": True},
                {"key": "player", "label": "Player", "source": "player", "default": "", "enabled": True},
                {"key": "total", "label": "Total", "source": "total", "default": "", "enabled": True},
                {"key": "to_par", "label": "To Par", "source": "to_par", "default": "", "enabled": True},
                {"key": "today", "label": "Today", "source": "today", "default": "", "enabled": True},
                {"key": "thru", "label": "Thru", "source": "thru", "default": "", "enabled": True},
            ],
        },
        {
            "key": "full",
            "title": "Full normalized",
            "description": "Все нормализованные поля без ручного ограничения колонок.",
            "endpoint": "/vmix/golf/json/full.json",
            "root_key": "players",
            "default_limit": 0,
            "enabled": True,
            "show_tab": True,
            "tab_title": "Full",
            "sort_key": "position",
            "sort_dir": "asc",
            "columns": [],
        },
    ],
}

DEFAULT_VMIX_FUNCTIONS: dict[str, Any] = {
    "version": 1,
    "title": "Функции формул vMix JSON",
    "description": "Справочник функций, которые доступны в колонках типа Формула.",
    "categories": [
        {
            "title": "Поля и сборка строк",
            "items": [
                {"name": "get", "example": "get('player')", "description": "Взять поле по имени. Если поля нет — пустая строка."},
                {"name": "val", "example": "val('total', '-')", "description": "То же самое, что get()."},
                {"name": "concat", "example": "concat(position, '. ', player)", "description": "Склеить значения без разделителя."},
                {"name": "join", "example": "join(' / ', player, club, total)", "description": "Склеить только заполненные значения через разделитель."},
                {"name": "tpl", "example": "tpl('#{position} {player}  {total}')", "description": "Шаблон с подстановкой полей в фигурных скобках."},
                {"name": "coalesce", "example": "coalesce(player, name, '-')", "description": "Первое непустое значение."}
            ]
        },
        {
            "title": "Текст",
            "items": [
                {"name": "split", "example": "split(player, ' ', 0)", "description": "Разбить строку и взять часть по индексу."},
                {"name": "part", "example": "part(player, ' ', 1)", "description": "Короткий вариант split(value, sep, index)."},
                {"name": "left", "example": "left(player, 3)", "description": "Первые N символов."},
                {"name": "right", "example": "right(player, 3)", "description": "Последние N символов."},
                {"name": "mid", "example": "mid(player, 2, 4)", "description": "Фрагмент строки."},
                {"name": "replace", "example": "replace(player, '  ', ' ')", "description": "Обычная замена текста."},
                {"name": "upper/lower/title/strip", "example": "upper(country)", "description": "Регистр и очистка пробелов."},
                {"name": "swap", "example": "swap(player)", "description": "Поменять порядок частей строки: Иван Петров → Петров Иван."}
            ]
        },
        {
            "title": "Условия",
            "items": [
                {"name": "case / iif", "example": "case(to_par == 0, 'E', to_par)", "description": "Если условие истинно — первое значение, иначе второе."},
                {"name": "empty / filled", "example": "if_empty(total, '-', total)", "description": "Проверка пустого/заполненного значения."},
                {"name": "if_empty", "example": "if_empty(thru, '—', thru)", "description": "Если пусто."},
                {"name": "if_filled", "example": "if_filled(country, country, '')", "description": "Если заполнено."},
                {"name": "if_contains", "example": "if_contains(status, 'active', 'LIVE', '')", "description": "Если строка содержит текст."},
                {"name": "if_not_contains", "example": "if_not_contains(status, 'active', '', 'LIVE')", "description": "Если строка не содержит текст."},
                {"name": "if_equals / if_eq", "example": "if_eq(to_par, '0', 'E', to_par)", "description": "Если значение равно ожидаемому."},
                {"name": "if_field_empty", "example": "if_field_empty('today', '-', today)", "description": "Проверить поле по имени."}
            ]
        },
        {
            "title": "Поиск и Regex",
            "items": [
                {"name": "contains", "example": "contains(player, 'Иван')", "description": "True/False: содержит текст."},
                {"name": "starts / ends", "example": "starts(player, 'A')", "description": "Начинается/заканчивается на текст."},
                {"name": "regex / match", "example": "regex(player, '^A')", "description": "Проверка регулярным выражением."},
                {"name": "if_regex", "example": "if_regex(player, '^A', 'A', '')", "description": "Условие по регулярному выражению."},
                {"name": "re_replace", "example": "re_replace(title, '(\\d)(?=(\\d{3})+\\b)', '\\1.')", "description": "Regex-замена."},
                {"name": "re_replace_first", "example": "re_replace_first(text, '\\s+', ' ')", "description": "Заменить только первое совпадение."},
                {"name": "re_remove", "example": "re_remove(player, '\\s+')", "description": "Удалить по regex."},
                {"name": "re_extract", "example": "re_extract(player, '#(\\d+)', 1)", "description": "Достать группу из текста."},
                {"name": "first_match", "example": "first_match(title, {'СБ':'Барьеры', 'Бег':'Бег'}, '')", "description": "Первое совпавшее правило."}
            ]
        },
        {
            "title": "Числа, время, даты",
            "items": [
                {"name": "number / num", "example": "number(total, 0)", "description": "Преобразовать в число."},
                {"name": "int/float/round/min/max/abs/sum", "example": "round(number(total), 1)", "description": "Базовая математика Python."},
                {"name": "format_thousands", "example": "format_thousands(10000)", "description": "10.000."},
                {"name": "format_distance", "example": "format_distance('10000')", "description": "Формат дистанций: 10.000."},
                {"name": "time_seconds", "example": "time_seconds('1:02.34')", "description": "Время в секунды."},
                {"name": "format_time", "example": "format_time(62.34, 'auto_tt')", "description": "Секунды обратно во время."},
                {"name": "time_diff", "example": "time_diff(result, leader, 'auto_tt')", "description": "Разница двух времен."},
                {"name": "today", "example": "today('%d.%m.%Y')", "description": "Сегодняшняя дата."},
                {"name": "age / age_at", "example": "age_at(birthDate, today())", "description": "Возраст по дате рождения."},
                {"name": "birth_year", "example": "birth_year(birthDate)", "description": "Год рождения."}
            ]
        },
        {
            "title": "Гольф",
            "items": [
                {"name": "golf_score", "example": "golf_score(to_par)", "description": "0 → E, положительные с плюсом, отрицательные без изменений."},
                {"name": "plus_minus", "example": "plus_minus(to_par)", "description": "Добавить + к положительному числу."},
                {"name": "zero_as", "example": "zero_as(to_par, 'E')", "description": "Заменить 0 на нужный текст."}
            ]
        },
        {
            "title": "Склейка JSON",
            "items": [
                {"name": "match", "example": "player_id=id", "description": "Сопоставление ключей: слева поле основного JSON, справа поле подключаемого источника."},
                {"name": "fields", "example": "club=club\ncountry=country", "description": "Какие поля подтянуть: слева новое имя поля, справа поле источника."},
                {"name": "multiple", "example": "first / last / all / count", "description": "Что делать, если найдено несколько совпадений."},
                {"name": "source_type", "example": "scores / tournament / round / vmix_json / url", "description": "Тип подключаемого источника данных."}
            ]
        }
    ]
}


def _deep_merge(default: Any, loaded: Any) -> Any:
    if isinstance(default, dict) and isinstance(loaded, dict):
        result = deepcopy(default)
        for key, value in loaded.items():
            result[key] = _deep_merge(result.get(key), value) if key in result else value
        return result
    return loaded if loaded is not None else deepcopy(default)


def ensure_settings_files() -> None:
    SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
    if not SETTINGS_FILE.exists():
        save_json(SETTINGS_FILE, DEFAULT_SETTINGS)
    if not VMIX_JSON_FILE.exists():
        save_json(VMIX_JSON_FILE, DEFAULT_VMIX_JSON)
    if not VMIX_FUNCTIONS_FILE.exists():
        save_json(VMIX_FUNCTIONS_FILE, DEFAULT_VMIX_FUNCTIONS)


def load_json(path: Path, default: dict[str, Any]) -> dict[str, Any]:
    ensure_settings_files() if path in (SETTINGS_FILE, VMIX_JSON_FILE, VMIX_FUNCTIONS_FILE) and not path.exists() else None
    try:
        with path.open("r", encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict):
            return deepcopy(default)
        return _deep_merge(default, data)
    except Exception:
        return deepcopy(default)


def save_json(path: Path, data: dict[str, Any]) -> dict[str, Any]:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    return data


def read_settings() -> dict[str, Any]:
    ensure_settings_files()
    return load_json(SETTINGS_FILE, DEFAULT_SETTINGS)


def write_settings(data: dict[str, Any]) -> dict[str, Any]:
    merged = _deep_merge(DEFAULT_SETTINGS, data)
    return save_json(SETTINGS_FILE, merged)



def _enabled_column_keys(config: dict[str, Any]) -> list[str]:
    columns = config.get("columns") or []
    if not isinstance(columns, list):
        return []
    keys: list[str] = []
    for column in columns:
        if not isinstance(column, dict):
            continue
        key = str(column.get("key") or "").strip()
        if key and column.get("enabled", True) is not False:
            keys.append(key)
    return keys


def _normalize_web_display(config: dict[str, Any]) -> dict[str, Any]:
    """Normalize portable web-display settings for any project.

    Older projects stored tab settings directly on the vMix JSON config
    (`show_tab`, `tab_title`, `sort_key`, `sort_dir`). New projects keep
    these in `web_display` so vMix output and web table output can differ.
    """
    web = config.get("web_display")
    if not isinstance(web, dict):
        web = {}

    web.setdefault("enabled", config.get("show_tab", True))
    web.setdefault("tab_title", config.get("tab_title") or config.get("title") or config.get("key") or "Вкладка")
    web.setdefault("description", config.get("data_source_description") or config.get("description") or "")
    web.setdefault("columns", _enabled_column_keys(config))
    web.setdefault("sort_key", config.get("sort_key") or config.get("tab_sort_key") or "")
    web.setdefault("sort_dir", config.get("sort_dir") or config.get("tab_sort_dir") or "asc")

    if not isinstance(web.get("columns"), list):
        if isinstance(web.get("columns"), str):
            web["columns"] = [x.strip() for x in web["columns"].replace(";", ",").split(",") if x.strip()]
        else:
            web["columns"] = []

    web["enabled"] = bool(web.get("enabled", True))
    web["tab_title"] = str(web.get("tab_title") or config.get("title") or config.get("key") or "Вкладка")
    web["sort_dir"] = "desc" if str(web.get("sort_dir") or "asc").lower() == "desc" else "asc"
    config["web_display"] = web
    return web


def _normalize_vmix_json_schema(data: dict[str, Any]) -> dict[str, Any]:
    data = deepcopy(data)
    data.setdefault("version", 3)
    configs = data.setdefault("configs", [])
    if isinstance(configs, list):
        for config in configs:
            if isinstance(config, dict):
                config.setdefault("joins", [])
                config.setdefault("output_mode", "columns")
                config.setdefault("default_limit", 0)
                _normalize_web_display(config)
    return data

def read_vmix_json() -> dict[str, Any]:
    ensure_settings_files()
    return _normalize_vmix_json_schema(load_json(VMIX_JSON_FILE, DEFAULT_VMIX_JSON))


def write_vmix_json(data: dict[str, Any]) -> dict[str, Any]:
    if "configs" not in data or not isinstance(data["configs"], list):
        raise ValueError("vmix_json должен содержать список configs")
    return save_json(VMIX_JSON_FILE, _normalize_vmix_json_schema(data))


def read_vmix_functions() -> dict[str, Any]:
    ensure_settings_files()
    return load_json(VMIX_FUNCTIONS_FILE, DEFAULT_VMIX_FUNCTIONS)


def write_vmix_functions(data: dict[str, Any]) -> dict[str, Any]:
    data.setdefault("version", 1)
    data.setdefault("categories", [])
    return save_json(VMIX_FUNCTIONS_FILE, data)



def read_selection() -> dict[str, Any]:
    return read_settings().get("selection", {}) or {}


def write_selection(selection: dict[str, Any]) -> dict[str, Any]:
    settings = read_settings()
    settings["selection"] = selection
    write_settings(settings)
    return selection


def get_vmix_config(key: str | None = None) -> dict[str, Any]:
    data = read_vmix_json()
    configs = data.get("configs", [])
    if not configs:
        raise KeyError("Нет vMix JSON конфигов")
    wanted = key or read_settings().get("scores", {}).get("active_vmix_json") or "leaderboard"
    for config in configs:
        if str(config.get("key")) == str(wanted):
            return config
    for config in configs:
        if str(config.get("key")) == "leaderboard":
            return config
    return configs[0]


def settings_meta() -> dict[str, str]:
    ensure_settings_files()
    return {
        "settings_dir": str(SETTINGS_DIR),
        "settings_file": str(SETTINGS_FILE),
        "vmix_json_file": str(VMIX_JSON_FILE),
        "vmix_functions_file": str(VMIX_FUNCTIONS_FILE),
    }


# ---------------------------------------------------------------------------
# Universal settings import
# ---------------------------------------------------------------------------

COMMON_SETTINGS_KEYS = {"version", "ui", "selection", "scores", "api", "modules", "features", "paths"}
CONFIG_LIST_KEYS = (
    "configs", "json_configs", "jsonConfigs", "vmix_json", "vmixJson", "vmix",
    "jsons", "outputs", "endpoints", "presets", "vmix_presets", "vmixPresets",
)
COLUMN_LIST_KEYS = (
    "columns", "fields", "selected_columns", "selectedColumns", "items", "rows",
    "schema", "mapping", "mappings",
)


def make_settings_backup(reason: str = "manual") -> dict[str, str]:
    """Copy current settings files before a destructive import/save operation."""
    ensure_settings_files()
    BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    safe_reason = _slugify(reason) or "backup"
    out: dict[str, str] = {}
    for src in (SETTINGS_FILE, VMIX_JSON_FILE):
        if not src.exists():
            continue
        dst = BACKUPS_DIR / f"{src.stem}_{stamp}_{safe_reason}{src.suffix}"
        dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
        out[src.name] = str(dst)
    return out


def _slugify(value: Any, fallback: str = "imported_json") -> str:
    text = str(value or "").strip().lower()
    text = text.replace("/", "_").replace("\\", "_")
    text = re.sub(r"[^a-zа-яё0-9_\-]+", "_", text, flags=re.IGNORECASE)
    text = re.sub(r"_+", "_", text).strip("_")
    return text or fallback


def _path_join(path: str, key: Any) -> str:
    if path:
        return f"{path}.{key}"
    return str(key)


def _walk_settings(obj: Any, path: str = "root", depth: int = 0, max_depth: int = 8):
    yield path, obj
    if depth >= max_depth:
        return
    if isinstance(obj, dict):
        for key, value in obj.items():
            yield from _walk_settings(value, _path_join(path, key), depth + 1, max_depth)
    elif isinstance(obj, list):
        for index, value in enumerate(obj[:200]):
            yield from _walk_settings(value, f"{path}[{index}]", depth + 1, max_depth)


def _first_value(d: dict[str, Any], *keys: str, default: Any = "") -> Any:
    for key in keys:
        if key in d and d[key] not in (None, ""):
            return d[key]
    return default


def _to_bool(value: Any, default: bool = True) -> bool:
    if value is None:
        return default
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return bool(value)
    text = str(value).strip().lower()
    if text in {"0", "false", "no", "off", "disabled", "hide", "hidden"}:
        return False
    if text in {"1", "true", "yes", "on", "enabled", "show", "visible"}:
        return True
    return default


def normalize_import_column(column: Any, fallback_index: int = 1) -> dict[str, Any] | None:
    """Normalize a column from many possible project formats to the portable format."""
    if isinstance(column, str):
        key = _slugify(column, fallback=f"field_{fallback_index}")
        return {"key": key, "label": column, "source": column, "default": "", "enabled": True}

    if isinstance(column, (int, float)):
        text = str(column)
        return {"key": f"field_{fallback_index}", "label": text, "source": text, "default": "", "enabled": True}

    if not isinstance(column, dict):
        return None

    raw_key = _first_value(
        column,
        "key", "json_key", "jsonKey", "field", "name", "id", "column", "column_name", "columnName",
        "target", "output", "title", "label",
        default=f"field_{fallback_index}",
    )
    key = _slugify(raw_key, fallback=f"field_{fallback_index}")
    label = str(_first_value(column, "label", "title", "caption", "name", "header", "display", default=raw_key))
    source = _first_value(
        column,
        "source", "src", "path", "from", "field", "value", "template", "expression", "formula",
        "data_key", "dataKey", "raw", "key",
        default=raw_key,
    )
    default = _first_value(column, "default", "default_value", "defaultValue", "fallback", "empty", default="")
    enabled = _to_bool(_first_value(column, "enabled", "visible", "show", "is_visible", "isVisible", default=True), True)

    return {
        "key": key,
        "label": label,
        "source": str(source),
        "default": default,
        "enabled": enabled,
    }


def _normalize_columns(value: Any) -> list[dict[str, Any]]:
    if isinstance(value, dict):
        # Format like {"player": "athlete.name", "total": {"source": "score"}}
        items: list[Any] = []
        for key, val in value.items():
            if isinstance(val, dict):
                merged = {"key": key, **val}
            else:
                merged = {"key": key, "label": key, "source": val}
            items.append(merged)
    elif isinstance(value, list):
        items = value
    else:
        return []

    result: list[dict[str, Any]] = []
    seen: set[str] = set()
    for index, item in enumerate(items, start=1):
        col = normalize_import_column(item, index)
        if not col:
            continue
        key = str(col["key"])
        if key in seen:
            base = key
            n = 2
            while f"{base}_{n}" in seen:
                n += 1
            col["key"] = f"{base}_{n}"
        seen.add(str(col["key"]))
        result.append(col)
    return result






def _normalize_etiming_output_columns(value: Any) -> list[dict[str, Any]]:
    """Preserve exact legacy eTiming column names for output."""
    items: list[Any]
    if isinstance(value, dict):
        items = []
        for key, val in value.items():
            if isinstance(val, dict):
                items.append({"key": key, "label": key, **val})
            else:
                items.append({"key": key, "label": key, "source": val})
    elif isinstance(value, list):
        items = value
    else:
        return []

    result: list[dict[str, Any]] = []
    for index, item in enumerate(items, start=1):
        if isinstance(item, str):
            name = item.strip()
            if name:
                result.append({"key": name, "label": name, "source": name, "default": "", "enabled": True, "mode": "field"})
        elif isinstance(item, dict):
            raw_key = _first_value(item, "key", "name", "field", "column", "label", default=f"field_{index}")
            key = str(raw_key).strip() or f"field_{index}"
            result.append({
                "key": key,
                "label": str(_first_value(item, "label", "title", "caption", "name", default=key)),
                "source": str(_first_value(item, "source", "src", "path", "field", "value", "key", default=key)),
                "default": _first_value(item, "default", "default_value", "fallback", default=""),
                "enabled": _to_bool(_first_value(item, "enabled", "visible", "show", default=True), True),
                "mode": str(_first_value(item, "mode", "type", default="field")),
            })
    return result

def _normalize_etiming_columns(value: Any, transforms: list[dict[str, Any]] | None = None) -> tuple[list[dict[str, Any]], bool]:
    """Convert eTiming vmixColumns + vmixTransforms to portable column rules.

    eTiming stores output columns in settings.json like:
      vmixColumns: {"results": "all", "info": ["title", ...]}
      vmixTransforms: {"results": [{"name": "Mask", "expr": "if_empty(...)"}]}
      vmixRows: {"results": 10}

    The portable golf project stores one config with a list of editable columns.
    Formula rules are represented as columns with mode="expr" and expr="...".
    """
    columns: list[dict[str, Any]] = []
    all_fields = False

    if isinstance(value, str) and value.strip().lower() == "all":
        all_fields = True
    else:
        columns = _normalize_etiming_output_columns(value)

    for index, rule in enumerate(transforms or [], start=1):
        if not isinstance(rule, dict):
            continue
        name = str(_first_value(rule, "name", "key", "field", "column", default=f"computed_{index}")).strip()
        expr = str(_first_value(rule, "expr", "expression", "formula", "source", default="")).strip()
        if not name or not expr:
            continue
        col = {
            "key": _slugify(name, fallback=f"computed_{index}"),
            "label": name,
            "source": expr,
            "expr": expr,
            "mode": "expr",
            "default": "",
            "enabled": _to_bool(_first_value(rule, "enabled", "visible", "show", default=True), True),
        }
        # Preserve the exact output name from eTiming, even if it contains uppercase/underscore.
        col["key"] = name
        columns.append(col)

    # Deduplicate keys while preserving imported names.
    seen: set[str] = set()
    result: list[dict[str, Any]] = []
    for col in columns:
        key = str(col.get("key") or "").strip()
        if not key:
            continue
        if key in seen:
            base = key
            n = 2
            while f"{base}_{n}" in seen:
                n += 1
            col = deepcopy(col)
            col["key"] = f"{base}_{n}"
        seen.add(str(col.get("key")))
        result.append(col)

    return result, all_fields


def _scan_etiming_vmix_settings(data: Any) -> list[dict[str, Any]]:
    """Find legacy eTiming vmixColumns/vmixTransforms/vmixRows blocks."""
    if not isinstance(data, dict):
        return []

    source = data.get("settings") if isinstance(data.get("settings"), dict) else data
    if not isinstance(source, dict):
        return []

    vmix_columns = source.get("vmixColumns") or source.get("vmix_columns")
    vmix_transforms = source.get("vmixTransforms") or source.get("vmixComputedColumns") or source.get("vmix_transforms") or {}
    vmix_rows = source.get("vmixRows") or source.get("vmix_rows") or {}

    if not isinstance(vmix_columns, dict):
        return []

    candidates: list[dict[str, Any]] = []
    title_map = {
        "info": "Info",
        "schedule-list-5": "Schedule List 5",
        "schedule-list": "Schedule List",
        "results": "Results",
        "current-athlete": "Current Athlete",
        "timetronics": "TimeTronics",
        "timetronics-splits": "TimeTronics Splits",
        "combined-table": "Combined Table",
        "combined-results": "Combined Results",
    }

    for endpoint_key, columns_value in vmix_columns.items():
        key = str(endpoint_key).strip()
        if not key:
            continue
        raw_transforms = vmix_transforms.get(key, []) if isinstance(vmix_transforms, dict) else []
        if isinstance(raw_transforms, dict):
            raw_transforms = list(raw_transforms.values())
        if not isinstance(raw_transforms, list):
            raw_transforms = []

        columns, all_fields = _normalize_etiming_columns(columns_value, raw_transforms)
        try:
            limit = int(vmix_rows.get(key, 0) if isinstance(vmix_rows, dict) else 0)
        except Exception:
            limit = 0

        notes = ["legacy eTiming: vmixColumns/vmixTransforms/vmixRows"]
        if all_fields:
            notes.append("вывод колонок: all")
        if raw_transforms:
            notes.append(f"формул: {len(raw_transforms)}")

        candidates.append({
            "key": _slugify(key, fallback="imported_json"),
            "title": title_map.get(key, key),
            "description": f"Импортировано из eTiming settings.json: {key}",
            "endpoint": f"/vmix/golf/json/{_slugify(key, fallback='imported_json')}.json",
            "root_key": "items" if key in {"info", "timetronics"} else "players",
            "default_limit": max(0, limit),
            "enabled": True,
            "output_mode": "all" if all_fields else "columns",
            "columns": columns,
            "import_meta": {
                "source_path": f"root.vmixColumns.{key}",
                "confidence": 98,
                "notes": notes,
                "legacy_format": "etiming",
            },
        })

    # Also include transform-only endpoints like current-athlete in the uploaded eTiming file.
    if isinstance(vmix_transforms, dict):
        for endpoint_key, raw_transforms in vmix_transforms.items():
            key = str(endpoint_key).strip()
            if not key or key in vmix_columns:
                continue
            if not isinstance(raw_transforms, list):
                continue
            columns, _ = _normalize_etiming_columns([], raw_transforms)
            if not columns:
                continue
            candidates.append({
                "key": _slugify(key, fallback="imported_json"),
                "title": title_map.get(key, key),
                "description": f"Импортировано из eTiming vmixTransforms: {key}",
                "endpoint": f"/vmix/golf/json/{_slugify(key, fallback='imported_json')}.json",
                "root_key": "players",
                "default_limit": int(vmix_rows.get(key, 0) if isinstance(vmix_rows, dict) else 0),
                "enabled": True,
                "output_mode": "columns",
                "columns": columns,
                "import_meta": {
                    "source_path": f"root.vmixTransforms.{key}",
                    "confidence": 92,
                    "notes": ["legacy eTiming: endpoint найден только в vmixTransforms", f"формул: {len(raw_transforms)}"],
                    "legacy_format": "etiming",
                },
            })

    return candidates

def _extract_columns_from_dict(obj: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
    for key in COLUMN_LIST_KEYS:
        if key in obj:
            cols = _normalize_columns(obj.get(key))
            if cols:
                return key, cols
    return "", []


def _looks_like_config(obj: Any) -> bool:
    if not isinstance(obj, dict):
        return False
    _, cols = _extract_columns_from_dict(obj)
    if cols:
        return True
    return any(k in obj for k in ("endpoint", "root_key", "rootKey", "default_limit", "defaultLimit")) and any(
        k in obj for k in ("key", "name", "title", "id")
    )


def normalize_import_vmix_config(obj: Any, fallback_key: str, source_path: str) -> dict[str, Any] | None:
    if not isinstance(obj, dict):
        return None

    column_source_key, columns = _extract_columns_from_dict(obj)
    if not columns and not _looks_like_config(obj):
        return None

    raw_key = _first_value(
        obj,
        "key", "json_key", "jsonKey", "name", "id", "slug", "title", "endpoint",
        default=fallback_key,
    )
    key = _slugify(raw_key, fallback=_slugify(fallback_key))
    title = str(_first_value(obj, "title", "label", "name", "caption", default=key))
    root_key = str(_first_value(obj, "root_key", "rootKey", "root", "items_key", "itemsKey", default="players"))
    endpoint = str(_first_value(obj, "endpoint", "url", "path", default=f"/vmix/golf/json/{key}.json"))
    if not endpoint.startswith("/") and not endpoint.startswith("http"):
        endpoint = f"/vmix/golf/json/{key}.json"
    default_limit_raw = _first_value(obj, "default_limit", "defaultLimit", "limit", "rows_limit", "rowsLimit", default=0)
    try:
        default_limit = int(default_limit_raw or 0)
    except Exception:
        default_limit = 0

    confidence = 70
    notes = []
    if columns:
        confidence += 20
        notes.append(f"найдено колонок: {len(columns)}")
    if column_source_key:
        notes.append(f"источник колонок: {column_source_key}")
    if "endpoint" in obj or "url" in obj or "path" in obj:
        confidence += 5
    if "root_key" in obj or "rootKey" in obj:
        confidence += 5

    return {
        "key": key,
        "title": title,
        "description": str(_first_value(obj, "description", "desc", "comment", default=f"Импортировано из {source_path}")),
        "endpoint": endpoint,
        "root_key": root_key,
        "default_limit": default_limit,
        "enabled": _to_bool(_first_value(obj, "enabled", "visible", "show", default=True), True),
        "columns": columns,
        "import_meta": {
            "source_path": source_path,
            "confidence": min(confidence, 100),
            "notes": notes,
        },
    }


def _add_unique_candidate(candidates: list[dict[str, Any]], candidate: dict[str, Any]) -> None:
    signature = (str(candidate.get("key")), str(candidate.get("import_meta", {}).get("source_path")), len(candidate.get("columns") or []))
    for existing in candidates:
        existing_signature = (
            str(existing.get("key")),
            str(existing.get("import_meta", {}).get("source_path")),
            len(existing.get("columns") or []),
        )
        if existing_signature == signature:
            return
    candidates.append(candidate)


def _scan_vmix_candidates(data: Any) -> list[dict[str, Any]]:
    candidates: list[dict[str, Any]] = []

    # First handle the legacy eTiming settings format explicitly.
    for candidate in _scan_etiming_vmix_settings(data):
        _add_unique_candidate(candidates, candidate)

    for path, obj in _walk_settings(data):
        if isinstance(obj, dict):
            # Direct single config: {key, title, columns: [...]}.
            candidate = normalize_import_vmix_config(obj, fallback_key=path.split(".")[-1], source_path=path)
            if candidate:
                _add_unique_candidate(candidates, candidate)

            # Common container: {configs: [{...}, {...}]}.
            for list_key in CONFIG_LIST_KEYS:
                value = obj.get(list_key)
                if isinstance(value, list):
                    for index, item in enumerate(value, start=1):
                        candidate = normalize_import_vmix_config(item, fallback_key=f"{list_key}_{index}", source_path=f"{path}.{list_key}[{index - 1}]")
                        if candidate:
                            _add_unique_candidate(candidates, candidate)
                elif isinstance(value, dict):
                    for child_key, child in value.items():
                        if isinstance(child, dict):
                            candidate = normalize_import_vmix_config(child, fallback_key=str(child_key), source_path=f"{path}.{list_key}.{child_key}")
                            if candidate:
                                _add_unique_candidate(candidates, candidate)

            # Mapping format: {leaderboard: {columns: [...]}, current: {columns: [...]}}
            config_like_children = [
                (key, val) for key, val in obj.items()
                if isinstance(val, dict) and _looks_like_config(val)
            ]
            if len(config_like_children) >= 1:
                for child_key, child in config_like_children:
                    candidate = normalize_import_vmix_config(child, fallback_key=str(child_key), source_path=f"{path}.{child_key}")
                    if candidate:
                        _add_unique_candidate(candidates, candidate)

        elif isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj):
            # A bare list of configs.
            config_count = sum(1 for x in obj if _looks_like_config(x))
            if config_count >= max(1, len(obj) // 2):
                for index, item in enumerate(obj, start=1):
                    candidate = normalize_import_vmix_config(item, fallback_key=f"json_{index}", source_path=f"{path}[{index - 1}]")
                    if candidate:
                        _add_unique_candidate(candidates, candidate)

    # If no configs were found but there is a selected_columns-like setting, create a basic config.
    for path, obj in _walk_settings(data):
        if isinstance(obj, dict):
            for key in ("selected_columns", "selectedColumns"):
                if key in obj:
                    cols = _normalize_columns(obj[key])
                    if cols:
                        _add_unique_candidate(candidates, {
                            "key": "imported_selected_columns",
                            "title": "Imported selected columns",
                            "description": f"Собрано из {path}.{key}",
                            "endpoint": "/vmix/golf/json/imported_selected_columns.json",
                            "root_key": "players",
                            "default_limit": int(obj.get("limit") or obj.get("default_limit") or 0),
                            "enabled": True,
                            "columns": cols,
                            "import_meta": {"source_path": f"{path}.{key}", "confidence": 65, "notes": ["fallback из selected_columns"]},
                        })

    # Sort stronger candidates first.
    candidates.sort(key=lambda item: (item.get("import_meta", {}).get("confidence", 0), len(item.get("columns") or [])), reverse=True)
    return candidates


def _extract_general_settings(data: Any) -> dict[str, Any]:
    if not isinstance(data, dict):
        return {}
    source = data.get("settings") if isinstance(data.get("settings"), dict) else data
    if not isinstance(source, dict):
        return {}
    general = {key: deepcopy(value) for key, value in source.items() if key in COMMON_SETTINGS_KEYS}
    # Do not import selection by default: tournament/round IDs from another project are usually invalid here.
    general.pop("selection", None)
    return general


def preview_settings_import(data: Any, file_name: str = "settings.json") -> dict[str, Any]:
    if not isinstance(data, (dict, list)):
        raise ValueError("Файл настроек должен быть JSON-объектом или JSON-массивом")

    candidates = _scan_vmix_candidates(data)
    general = _extract_general_settings(data)
    warnings: list[str] = []
    if not candidates:
        warnings.append("Не найдено vMix JSON конфигураций. Можно импортировать только общие настройки, если они есть.")
    if not general:
        warnings.append("Общие настройки проекта не найдены или не похожи на переносимый формат.")

    return {
        "file_name": file_name,
        "source_format": "legacy-etiming/vmixColumns" if any((c.get("import_meta") or {}).get("legacy_format") == "etiming" for c in candidates) else ("portable/vmix-json" if candidates else "generic-json"),
        "general_settings": general,
        "vmix_configs": candidates,
        "summary": {
            "general_keys": list(general.keys()),
            "vmix_configs_found": len(candidates),
            "columns_found": sum(len(c.get("columns") or []) for c in candidates),
        },
        "warnings": warnings,
    }


def _unique_key(base_key: str, existing: set[str]) -> str:
    key = _slugify(base_key)
    if key not in existing:
        existing.add(key)
        return key
    n = 2
    while f"{key}_{n}" in existing:
        n += 1
    final = f"{key}_{n}"
    existing.add(final)
    return final


def _clean_imported_config(config: dict[str, Any]) -> dict[str, Any]:
    # Previews produced by this module are already normalized. Preserve portable
    # extras such as output_mode and expression column metadata.
    if isinstance(config, dict) and "columns" in config and "key" in config:
        clean = deepcopy(config)
        clean.pop("import_meta", None)
        clean.setdefault("endpoint", f"/vmix/golf/json/{_slugify(clean.get('key'))}.json")
        clean.setdefault("root_key", "players")
        clean.setdefault("default_limit", 0)
        clean.setdefault("enabled", True)
        clean["columns"] = [c for c in (clean.get("columns") or []) if isinstance(c, dict)]
        return clean

    clean = normalize_import_vmix_config(config, fallback_key=str(config.get("key") or "imported_json"), source_path=str(config.get("import_meta", {}).get("source_path", "import.apply")))
    if not clean:
        raise ValueError("Некорректная конфигурация vMix JSON")
    clean.pop("import_meta", None)
    return clean


def apply_settings_import(
    preview: dict[str, Any],
    selected_indexes: list[int] | None = None,
    import_general: bool = False,
    import_vmix: bool = True,
    mode: str = "add_new",
) -> dict[str, Any]:
    """
    Apply a normalized preview.

    mode:
    - add_new: add imported configs and rename duplicates
    - update_existing: replace configs with same key, add missing
    - replace_all: replace current vmix_json configs with selected imported configs
    """
    if not isinstance(preview, dict):
        raise ValueError("preview должен быть объектом")

    backups = make_settings_backup("before_import")
    imported_configs = preview.get("vmix_configs") or []
    if selected_indexes is None:
        selected_indexes = list(range(len(imported_configs)))

    selected: list[dict[str, Any]] = []
    for index in selected_indexes:
        try:
            item = imported_configs[int(index)]
        except Exception:
            continue
        if isinstance(item, dict):
            selected.append(_clean_imported_config(item))

    current_settings = read_settings()
    current_vmix = read_vmix_json()

    general_changed = False
    if import_general:
        general = preview.get("general_settings") or {}
        if isinstance(general, dict) and general:
            # Preserve current selection because imported IDs usually belong to another project.
            saved_selection = current_settings.get("selection", {})
            current_settings = _deep_merge(current_settings, general)
            current_settings["selection"] = saved_selection
            write_settings(current_settings)
            general_changed = True

    vmix_changed = False
    if import_vmix and selected:
        existing_configs = current_vmix.get("configs") or []
        if mode == "replace_all":
            final_configs = selected
        elif mode == "update_existing":
            by_key = {str(c.get("key")): deepcopy(c) for c in existing_configs if isinstance(c, dict)}
            order = [str(c.get("key")) for c in existing_configs if isinstance(c, dict)]
            for item in selected:
                key = str(item.get("key"))
                if key not in by_key:
                    order.append(key)
                by_key[key] = item
            final_configs = [by_key[key] for key in order if key in by_key]
        else:  # add_new
            existing = {str(c.get("key")) for c in existing_configs if isinstance(c, dict)}
            final_configs = [deepcopy(c) for c in existing_configs if isinstance(c, dict)]
            for item in selected:
                item = deepcopy(item)
                item["key"] = _unique_key(str(item.get("key") or "imported_json"), existing)
                if item.get("endpoint", "").startswith("/vmix/golf/json/") or not item.get("endpoint"):
                    item["endpoint"] = f"/vmix/golf/json/{item['key']}.json"
                final_configs.append(item)

        current_vmix["configs"] = final_configs
        write_vmix_json(current_vmix)
        vmix_changed = True

    return {
        "settings": read_settings(),
        "vmix_json": read_vmix_json(),
        "meta": settings_meta() | {"backups": backups},
        "imported": {
            "general_changed": general_changed,
            "vmix_changed": vmix_changed,
            "configs_selected": len(selected),
            "mode": mode,
        },
    }
