from __future__ import annotations

import json
import logging
import shutil
from dataclasses import dataclass, field
from pathlib import Path

logger = logging.getLogger(__name__)

DEFAULT_CONFIG_PATH = Path.home() / ".config" / "titletimer" / "config.json"
# Ships inside the titletimer package dir itself (not the repo-root
# config.example.json, which is just a symlink to this) so it's included
# automatically wherever src/titletimer is bundled - AppImage included -
# without needing a separate packaging step.
EXAMPLE_CONFIG_PATH = Path(__file__).parent / "config.example.json"
# Same reasoning as EXAMPLE_CONFIG_PATH above - ships inside the package
# dir so `cp -r src/titletimer` in build-appimage.sh picks it up for free,
# no separate packaging step. Used for _NET_WM_ICON (see x11_overlay.py:
# set_wm_icon) since GTK4 dropped gtk_window_set_icon*() entirely and an
# unintegrated AppImage run has no .desktop file installed anywhere a WM
# could resolve Icon=titletimer from.
APP_ICON_PATH = Path(__file__).parent / "app_icon.png"

# Each character/profile is its own full config.json under this directory
# (e.g. profiles/Chung.json, profiles/Elsword.json) - see resolve_active_profile
# for the migration from the old single-file layout and how the
# last-active one is remembered across restarts.
PROFILES_DIR = Path.home() / ".config" / "titletimer" / "profiles"
LAST_PROFILE_FILE = Path.home() / ".config" / "titletimer" / "last_profile.txt"
DEFAULT_PROFILE_NAME = "Default"

# UI language is an app-wide preference, not per-character, so it lives
# next to LAST_PROFILE_FILE rather than inside any profile's config.json.
LANGUAGE_FILE = Path.home() / ".config" / "titletimer" / "language.txt"
DEFAULT_LANGUAGE = "de"
VALID_LANGUAGES = {"de", "en"}

VALID_ANCHORS = {"top-left", "top-right", "bottom-left", "bottom-right"}


@dataclass
class TitleConfig:
    id: str
    name: str
    # One or more evdev key names that must all be held down together to
    # trigger this title (e.g. ("KEY_LEFTCTRL", "KEY_F1")) - stored sorted
    # for a stable canonical order; matched as a set (see state.py), not a
    # press sequence.
    hotkey: tuple[str, ...]
    cooldown_seconds: float
    color: str = "#4da6ff"
    # Optional alternate combo that only does step 1 (IDLE -> STANDBY),
    # never step 2 - e.g. a movement key already pressed during normal
    # play, so the main `hotkey` only needs pressing once (for step 2)
    # instead of twice. None = no pre-activation combo configured.
    pre_hotkey: tuple[str, ...] | None = None
    # If set, STANDBY (step 1) automatically reverts to IDLE after this many
    # seconds without step 2 - so a forgotten/stale standby (especially one
    # entered via pre_hotkey, which can trigger far more casually than a
    # deliberate hotkey press) doesn't linger indefinitely. None = no
    # timeout, stays in STANDBY until step 2 or another title bumps it.
    standby_timeout_seconds: float | None = None
    # If True, pressing the main hotkey while COOLDOWN is already running
    # restarts it (fresh cooldown_seconds from now) instead of being
    # ignored - for buffs you want to be able to refresh/extend by
    # re-triggering rather than only ever timing out on their own.
    restart_on_repeat: bool = False
    # Absolute path to an image file (PNG/SVG/...), shown left of the name
    # in the overlay row. Optional - a title without one just shows text.
    icon: str | None = None


@dataclass
class OverlayConfig:
    anchor: str = "top-right"
    margin_x: int = 24
    margin_y: int = 24
    scale: float = 1.0
    # Absolute root-window position, in pixels. Set once the user
    # Ctrl+Alt-drags the overlay (see drag.py); overrides anchor/margin
    # placement when both are present so a dragged position survives
    # restarts.
    x: int | None = None
    y: int | None = None


@dataclass
class AppConfig:
    dogma_mode: bool = True
    overlay: OverlayConfig = field(default_factory=OverlayConfig)
    titles: list[TitleConfig] = field(default_factory=list)


def load_config(path: Path | None = None) -> AppConfig:
    path = path or DEFAULT_CONFIG_PATH
    if not path.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(EXAMPLE_CONFIG_PATH.read_text())
        logger.info("No config found, created default at %s from bundled example.", path)

    raw = json.loads(path.read_text())

    overlay_raw = raw.get("overlay", {})
    anchor = overlay_raw.get("anchor", "top-right")
    if anchor not in VALID_ANCHORS:
        raise ValueError(f"overlay.anchor must be one of {VALID_ANCHORS}, got {anchor!r}")

    overlay_x = overlay_raw.get("x")
    overlay_y = overlay_raw.get("y")
    overlay = OverlayConfig(
        anchor=anchor,
        margin_x=int(overlay_raw.get("margin_x", 24)),
        margin_y=int(overlay_raw.get("margin_y", 24)),
        scale=float(overlay_raw.get("scale", 1.0)),
        x=int(overlay_x) if overlay_x is not None else None,
        y=int(overlay_y) if overlay_y is not None else None,
    )

    titles_raw = raw.get("titles", [])
    if not titles_raw:
        raise ValueError("config must define at least one entry under 'titles'")

    seen_ids: set[str] = set()
    seen_hotkeys: set[frozenset[str]] = set()
    titles: list[TitleConfig] = []
    for entry in titles_raw:
        for required in ("id", "name", "hotkey", "cooldown_seconds"):
            if required not in entry:
                raise ValueError(f"title entry missing required field {required!r}: {entry}")

        title_id = entry["id"]
        hotkey_raw = entry["hotkey"]
        # Accept a bare string for backwards compatibility with configs
        # written before multi-key combos existed.
        hotkey = tuple(sorted((hotkey_raw,) if isinstance(hotkey_raw, str) else hotkey_raw))
        if not hotkey:
            raise ValueError(f"title entry has an empty hotkey: {entry}")
        hotkey_combo = frozenset(hotkey)

        pre_hotkey_raw = entry.get("pre_hotkey")
        pre_hotkey = tuple(sorted(pre_hotkey_raw)) if pre_hotkey_raw else None
        pre_hotkey_combo = frozenset(pre_hotkey) if pre_hotkey else None

        if title_id in seen_ids:
            raise ValueError(f"duplicate title id: {title_id}")
        if hotkey_combo in seen_hotkeys:
            raise ValueError(f"duplicate hotkey binding: {hotkey}")
        if pre_hotkey_combo is not None and pre_hotkey_combo in seen_hotkeys:
            raise ValueError(f"duplicate hotkey binding: {pre_hotkey}")
        seen_ids.add(title_id)
        seen_hotkeys.add(hotkey_combo)
        if pre_hotkey_combo is not None:
            seen_hotkeys.add(pre_hotkey_combo)

        standby_timeout = entry.get("standby_timeout_seconds")
        titles.append(
            TitleConfig(
                id=title_id,
                name=entry["name"],
                hotkey=hotkey,
                cooldown_seconds=float(entry["cooldown_seconds"]),
                color=entry.get("color", "#4da6ff"),
                pre_hotkey=pre_hotkey,
                standby_timeout_seconds=float(standby_timeout) if standby_timeout else None,
                restart_on_repeat=bool(entry.get("restart_on_repeat", False)),
                icon=entry.get("icon"),
            )
        )

    return AppConfig(
        dogma_mode=bool(raw.get("dogma_mode", True)),
        overlay=overlay,
        titles=titles,
    )


def save_config(path: Path, config: AppConfig) -> None:
    """Serialize a full AppConfig to disk - used by the settings window,
    which already holds a complete, validated AppConfig in memory.

    Unlike persist_overlay_position (a narrow read-modify-write patch),
    this rewrites the whole file, so any keys this app doesn't know about
    are lost - acceptable here since the settings window is the canonical
    editor for the whole schema.
    """
    raw: dict = {
        "dogma_mode": config.dogma_mode,
        "overlay": {
            "anchor": config.overlay.anchor,
            "margin_x": config.overlay.margin_x,
            "margin_y": config.overlay.margin_y,
            "scale": config.overlay.scale,
        },
        "titles": [
            {
                "id": t.id,
                "name": t.name,
                "hotkey": list(t.hotkey),
                "cooldown_seconds": t.cooldown_seconds,
                "color": t.color,
                **({"pre_hotkey": list(t.pre_hotkey)} if t.pre_hotkey else {}),
                **(
                    {"standby_timeout_seconds": t.standby_timeout_seconds}
                    if t.standby_timeout_seconds
                    else {}
                ),
                **({"restart_on_repeat": True} if t.restart_on_repeat else {}),
                **({"icon": t.icon} if t.icon else {}),
            }
            for t in config.titles
        ],
    }
    if config.overlay.x is not None and config.overlay.y is not None:
        raw["overlay"]["x"] = config.overlay.x
        raw["overlay"]["y"] = config.overlay.y

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(raw, indent=2))


def persist_overlay_position(path: Path, x: int, y: int) -> None:
    """Write a dragged overlay position back to the config file in place.

    Rewrites only overlay.x/overlay.y, leaving every other key (including
    ones this app doesn't know about) untouched.
    """
    raw = json.loads(path.read_text())
    raw.setdefault("overlay", {})["x"] = x
    raw["overlay"]["y"] = y
    path.write_text(json.dumps(raw, indent=2))


def load_language() -> str:
    if LANGUAGE_FILE.exists():
        lang = LANGUAGE_FILE.read_text().strip()
        if lang in VALID_LANGUAGES:
            return lang
    return DEFAULT_LANGUAGE


def save_language(lang: str) -> None:
    if lang not in VALID_LANGUAGES:
        raise ValueError(f"language must be one of {VALID_LANGUAGES}, got {lang!r}")
    LANGUAGE_FILE.parent.mkdir(parents=True, exist_ok=True)
    LANGUAGE_FILE.write_text(lang)


def _validate_profile_name(name: str) -> str:
    name = name.strip()
    if not name:
        raise ValueError("Charaktername darf nicht leer sein.")
    if "/" in name or "\\" in name or name in (".", ".."):
        raise ValueError(f"Ungültiger Charaktername: {name!r}")
    return name


def profile_path(name: str) -> Path:
    return PROFILES_DIR / f"{_validate_profile_name(name)}.json"


def list_profiles() -> list[str]:
    if not PROFILES_DIR.is_dir():
        return []
    return sorted(p.stem for p in PROFILES_DIR.glob("*.json"))


def resolve_active_profile() -> str:
    """Figure out which profile to load at startup.

    On the very first run under the profile system, migrates the old
    single `config.json` (if present) into `profiles/Default.json` so
    existing users keep their setup instead of falling back to the
    bundled example. Otherwise returns the last-active profile (see
    set_active_profile), falling back to the alphabetically first one if
    that name no longer exists (e.g. it was since deleted/renamed).
    """
    PROFILES_DIR.mkdir(parents=True, exist_ok=True)
    profiles = list_profiles()
    if not profiles:
        if DEFAULT_CONFIG_PATH.exists():
            DEFAULT_CONFIG_PATH.rename(profile_path(DEFAULT_PROFILE_NAME))
            logger.info(
                "Migrated existing config.json to profile %r.", DEFAULT_PROFILE_NAME
            )
        return DEFAULT_PROFILE_NAME

    if LAST_PROFILE_FILE.exists():
        last = LAST_PROFILE_FILE.read_text().strip()
        if last in profiles:
            return last
    return profiles[0]


def set_active_profile(name: str) -> None:
    LAST_PROFILE_FILE.parent.mkdir(parents=True, exist_ok=True)
    LAST_PROFILE_FILE.write_text(name)


def rename_profile(name: str, new_name: str) -> None:
    profile_path(name).rename(profile_path(new_name))


def duplicate_profile(name: str, new_name: str) -> None:
    shutil.copy(profile_path(name), profile_path(new_name))


def delete_profile(name: str) -> None:
    profile_path(name).unlink()
