# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper.  If not, see <https://www.gnu.org/licenses/>.

"""Migration functions.

Only write changes to disk, if there actually are changes. Otherwise, file-modification
dates are destroyed.
"""
from __future__ import annotations

import copy
import json
import os
import re
import shutil
from pathlib import Path
from typing import Iterator, Tuple, Dict, List, Optional, TypedDict

from evdev.ecodes import (
    EV_KEY,
    EV_ABS,
    EV_REL,
    ABS_X,
    ABS_Y,
    ABS_RX,
    ABS_RY,
    REL_X,
    REL_Y,
    REL_WHEEL_HI_RES,
    REL_HWHEEL_HI_RES,
)
from packaging import version

from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.macros.parse import Parser
from inputremapper.logging.logger import logger, VERSION
from inputremapper.user import UserUtils


class Config(TypedDict):
    input_combination: Optional[InputCombination]
    target_uinput: str
    output_type: int
    output_code: Optional[int]


class Migrations:
    def __init__(self, global_uinputs: GlobalUInputs):
        self.global_uinputs = global_uinputs

    def migrate(self):
        """Migrate config files to the current release."""

        self._rename_to_input_remapper()

        self._copy_to_v2()

        v = self.config_version()

        if v < version.parse("0.4.0"):
            self._config_suffix()
            self._preset_path()

        if v < version.parse("1.2.2"):
            self._mapping_keys()

        if v < version.parse("1.4.0"):
            self.global_uinputs.prepare_all()
            self._add_target()

        if v < version.parse("1.4.1"):
            self._otherwise_to_else()

        if v < version.parse("1.5.0"):
            self._remove_logs()

        if v < version.parse("1.6.0-beta"):
            self._convert_to_individual_mappings()

        # add new migrations here

        if v < version.parse(VERSION):
            self._update_version()

    def all_presets(
        self,
    ) -> Iterator[Tuple[os.PathLike, Dict | List]]:
        """Get all presets for all groups as list."""
        if not os.path.exists(PathUtils.get_preset_path()):
            return

        preset_path = Path(PathUtils.get_preset_path())
        for folder in preset_path.iterdir():
            if not folder.is_dir():
                continue

            for preset in folder.iterdir():
                if preset.suffix != ".json":
                    continue

                try:
                    with open(preset, "r") as f:
                        preset_structure = json.load(f)
                        yield preset, preset_structure
                except json.decoder.JSONDecodeError:
                    logger.warning('Invalid json format in preset "%s"', preset)
                    continue

    def config_version(self):
        """Get the version string in config.json as packaging.Version object."""
        config_path = os.path.join(PathUtils.config_path(), "config.json")

        if not os.path.exists(config_path):
            return version.parse("0.0.0")

        with open(config_path, "r") as file:
            config = json.load(file)

        if "version" in config.keys():
            return version.parse(config["version"])

        return version.parse("0.0.0")

    def _config_suffix(self):
        """Append the .json suffix to the config file."""
        deprecated_path = os.path.join(PathUtils.config_path(), "config")
        config_path = os.path.join(PathUtils.config_path(), "config.json")
        if os.path.exists(deprecated_path) and not os.path.exists(config_path):
            logger.info('Moving "%s" to "%s"', deprecated_path, config_path)
            os.rename(deprecated_path, config_path)

    def _preset_path(self):
        """Migrate the folder structure from < 0.4.0.

        Move existing presets into the new subfolder 'presets'
        """
        new_preset_folder = os.path.join(PathUtils.config_path(), "presets")
        if os.path.exists(PathUtils.get_preset_path()) or not os.path.exists(
            PathUtils.config_path()
        ):
            return

        logger.info("Migrating presets from < 0.4.0...")
        groups = os.listdir(PathUtils.config_path())
        PathUtils.mkdir(PathUtils.get_preset_path())
        for group in groups:
            path = os.path.join(PathUtils.config_path(), group)
            if os.path.isdir(path):
                target = path.replace(PathUtils.config_path(), new_preset_folder)
                logger.info('Moving "%s" to "%s"', path, target)
                os.rename(path, target)

        logger.info("done")

    def _mapping_keys(self):
        """Update all preset mappings.

        Update all keys in preset to include value e.g.: '1,5'->'1,5,1'
        """
        for preset, preset_structure in self.all_presets():
            if isinstance(preset_structure, list):
                continue  # the preset must be at least 1.6-beta version

            changes = 0
            if "mapping" in preset_structure.keys():
                mapping = copy.deepcopy(preset_structure["mapping"])
                for key in mapping.keys():
                    if key.count(",") == 1:
                        preset_structure["mapping"][f"{key},1"] = preset_structure[
                            "mapping"
                        ].pop(key)
                        changes += 1

            if changes:
                with open(preset, "w") as file:
                    logger.info('Updating mapping keys of "%s"', preset)
                    json.dump(preset_structure, file, indent=4)
                    file.write("\n")

    def _update_version(self):
        """Write the current version to the config file."""
        config_file = os.path.join(PathUtils.config_path(), "config.json")
        if not os.path.exists(config_file):
            return

        with open(config_file, "r") as file:
            config = json.load(file)

        config["version"] = VERSION
        with open(config_file, "w") as file:
            logger.info('Updating version in config to "%s"', VERSION)
            json.dump(config, file, indent=4)

    def _rename_to_input_remapper(self):
        """Rename .config/key-mapper to .config/input-remapper."""
        old_config_path = os.path.join(UserUtils.home, ".config/key-mapper")
        if not os.path.exists(PathUtils.config_path()) and os.path.exists(
            old_config_path
        ):
            logger.info("Moving %s to %s", old_config_path, PathUtils.config_path())
            shutil.move(old_config_path, PathUtils.config_path())

    def _find_target(self, symbol):
        """Try to find a uinput with the required capabilities for the symbol."""
        capabilities = {EV_KEY: set(), EV_REL: set()}

        if Parser.is_this_a_macro(symbol):
            # deprecated mechanic, cannot figure this out anymore
            # capabilities = parse(symbol).get_capabilities()
            return None

        capabilities[EV_KEY] = {keyboard_layout.get(symbol)}

        if len(capabilities[EV_REL]) > 0:
            return "mouse"

        for name, uinput in self.global_uinputs.devices.items():
            if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]):
                return name

        logger.info('could not find a suitable target UInput for "%s"', symbol)
        return None

    def _add_target(self):
        """Add the target field to each preset mapping."""
        for preset, preset_structure in self.all_presets():
            if isinstance(preset_structure, list):
                continue

            if "mapping" not in preset_structure.keys():
                continue

            changed = False
            for key, symbol in preset_structure["mapping"].copy().items():
                if isinstance(symbol, list):
                    continue

                target = self._find_target(symbol)
                if target is None:
                    target = "keyboard"
                    symbol = (
                        f"{symbol}\n"
                        "# Broken mapping:\n"
                        "# No target can handle all specified keycodes"
                    )

                logger.info(
                    'Changing target of mapping for "%s" in preset "%s" to "%s"',
                    key,
                    preset,
                    target,
                )
                symbol = [symbol, target]
                preset_structure["mapping"][key] = symbol
                changed = True

            if not changed:
                continue

            with open(preset, "w") as file:
                logger.info('Adding targets for "%s"', preset)
                json.dump(preset_structure, file, indent=4)
                file.write("\n")

    def _otherwise_to_else(self):
        """Conditional macros should use an "else" parameter instead of "otherwise"."""
        for preset, preset_structure in self.all_presets():
            if isinstance(preset_structure, list):
                continue

            if "mapping" not in preset_structure.keys():
                continue

            changed = False
            for key, symbol in preset_structure["mapping"].copy().items():
                if not Parser.is_this_a_macro(symbol[0]):
                    continue

                symbol_before = symbol[0]
                symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0])

                if symbol_before == symbol[0]:
                    continue

                changed = changed or symbol_before != symbol[0]

                logger.info(
                    'Changing mapping for "%s" in preset "%s" to "%s"',
                    key,
                    preset,
                    symbol[0],
                )

                preset_structure["mapping"][key] = symbol

            if not changed:
                continue

            with open(preset, "w") as file:
                logger.info('Changing otherwise to else for "%s"', preset)
                json.dump(preset_structure, file, indent=4)
                file.write("\n")

    def _input_combination_from_string(
        self, combination_string: str
    ) -> InputCombination:
        configs = []
        for event_str in combination_string.split("+"):
            type_, code, analog_threshold = event_str.split(",")
            configs.append(
                {
                    "type": int(type_),
                    "code": int(code),
                    "analog_threshold": int(analog_threshold),
                }
            )

        return InputCombination(configs)

    def _convert_to_individual_mappings(
        self,
    ) -> None:
        """Convert preset.json
        from {key: [symbol, target]}
        to [{input_combination: ..., output_symbol: symbol, ...}]
        """

        for old_preset_path, old_preset in self.all_presets():
            if isinstance(old_preset, list):
                continue

            migrated_preset = Preset(old_preset_path, UIMapping)
            if "mapping" in old_preset.keys():
                for combination, symbol_target in old_preset["mapping"].items():
                    logger.info(
                        'migrating from "%s: %s" to mapping dict',
                        combination,
                        symbol_target,
                    )
                    try:
                        combination = self._input_combination_from_string(combination)
                    except ValueError:
                        logger.error(
                            "unable to migrate mapping with invalid combination %s",
                            combination,
                        )
                        continue

                    mapping = UIMapping(
                        input_combination=combination,
                        target_uinput=symbol_target[1],
                        output_symbol=symbol_target[0],
                    )
                    migrated_preset.add(mapping)

            if (
                "gamepad" in old_preset.keys()
                and "joystick" in old_preset["gamepad"].keys()
            ):
                joystick_dict = old_preset["gamepad"]["joystick"]
                left_purpose = joystick_dict.get("left_purpose")
                right_purpose = joystick_dict.get("right_purpose")
                # TODO if pointer_speed is migrated, why is it in my config?
                pointer_speed = joystick_dict.get("pointer_speed")
                if pointer_speed:
                    pointer_speed /= 100
                # non_linearity = joystick_dict.get("non_linearity")  # Todo
                x_scroll_speed = joystick_dict.get("x_scroll_speed")
                y_scroll_speed = joystick_dict.get("y_scroll_speed")

                cfg: Config = {
                    "input_combination": None,
                    "target_uinput": "mouse",
                    "output_type": EV_REL,
                    "output_code": None,
                }

                if left_purpose == "mouse":
                    x_config = cfg.copy()
                    y_config = cfg.copy()
                    x_config["input_combination"] = InputCombination(
                        [InputConfig(type=EV_ABS, code=ABS_X)]
                    )
                    y_config["input_combination"] = InputCombination(
                        [InputConfig(type=EV_ABS, code=ABS_Y)]
                    )
                    x_config["output_code"] = REL_X
                    y_config["output_code"] = REL_Y
                    mapping_x = Mapping(**x_config)
                    mapping_y = Mapping(**y_config)
                    if pointer_speed:
                        mapping_x.gain = pointer_speed
                        mapping_y.gain = pointer_speed
                    migrated_preset.add(mapping_x)
                    migrated_preset.add(mapping_y)

                if right_purpose == "mouse":
                    x_config = cfg.copy()
                    y_config = cfg.copy()
                    x_config["input_combination"] = InputCombination(
                        [InputConfig(type=EV_ABS, code=ABS_RX)]
                    )
                    y_config["input_combination"] = InputCombination(
                        [InputConfig(type=EV_ABS, code=ABS_RY)]
                    )
                    x_config["output_code"] = REL_X
                    y_config["output_code"] = REL_Y
                    mapping_x = Mapping(**x_config)
                    mapping_y = Mapping(**y_config)
                    if pointer_speed:
                        mapping_x.gain = pointer_speed
                        mapping_y.gain = pointer_speed
                    migrated_preset.add(mapping_x)
                    migrated_preset.add(mapping_y)

                if left_purpose == "wheel":
                    x_config = cfg.copy()
                    y_config = cfg.copy()
                    x_config["input_combination"] = InputCombination(
                        [InputConfig(type=EV_ABS, code=ABS_X)]
                    )
                    y_config["input_combination"] = InputCombination(
                        [InputConfig(type=EV_ABS, code=ABS_Y)]
                    )
                    x_config["output_code"] = REL_HWHEEL_HI_RES
                    y_config["output_code"] = REL_WHEEL_HI_RES
                    mapping_x = Mapping(**x_config)
                    mapping_y = Mapping(**y_config)
                    if x_scroll_speed:
                        mapping_x.gain = x_scroll_speed
                    if y_scroll_speed:
                        mapping_y.gain = y_scroll_speed
                    migrated_preset.add(mapping_x)
                    migrated_preset.add(mapping_y)

                if right_purpose == "wheel":
                    x_config = cfg.copy()
                    y_config = cfg.copy()
                    x_config["input_combination"] = InputCombination(
                        [InputConfig(type=EV_ABS, code=ABS_RX)]
                    )
                    y_config["input_combination"] = InputCombination(
                        [InputConfig(type=EV_ABS, code=ABS_RY)]
                    )
                    x_config["output_code"] = REL_HWHEEL_HI_RES
                    y_config["output_code"] = REL_WHEEL_HI_RES
                    mapping_x = Mapping(**x_config)
                    mapping_y = Mapping(**y_config)
                    if x_scroll_speed:
                        mapping_x.gain = x_scroll_speed
                    if y_scroll_speed:
                        mapping_y.gain = y_scroll_speed
                    migrated_preset.add(mapping_x)
                    migrated_preset.add(mapping_y)

            migrated_preset.save()

    def _copy_to_v2(self):
        """Move the beta config to the v2 path, or copy the v1 config to the v2 path."""
        # TODO test
        if os.path.exists(PathUtils.config_path()):
            # don't copy to already existing folder
            # users should delete the input-remapper-2 folder if they need to
            return

        # prioritize the v1 configs over beta configs
        old_path = os.path.join(UserUtils.home, ".config/input-remapper")
        if os.path.exists(os.path.join(old_path, "config.json")):
            # no beta path, only old presets exist. COPY to v2 path, which will then be
            # migrated by the various self.
            logger.debug("copying all from %s to %s", old_path, PathUtils.config_path())
            shutil.copytree(old_path, PathUtils.config_path())
            return

        # if v1 configs don't exist, try to find beta configs.
        beta_path = os.path.join(
            UserUtils.home, ".config/input-remapper/beta_1.6.0-beta"
        )
        if os.path.exists(beta_path):
            # There has never been a different version than "1.6.0-beta" in beta, so we
            # only need to check for that exact directory
            # already migrated, possibly new presets in them, move to v2 path
            logger.debug("moving %s to %s", beta_path, PathUtils.config_path())
            shutil.move(beta_path, PathUtils.config_path())

    def _remove_logs(self):
        """We will try to rely on journalctl for this in the future."""
        try:
            PathUtils.remove(f"{UserUtils.home}/.log/input-remapper")
            PathUtils.remove("/var/log/input-remapper")
            PathUtils.remove("/var/log/input-remapper-control")
        except Exception as error:
            logger.debug("Failed to remove deprecated logfiles: %s", str(error))
            # this migration is not important. Continue
            pass
