# Orca
#
# Copyright 2026 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.

"""Shared migration logic for converting between JSON settings and GSettings.

This module has no imports of debug, settings_manager, or anything that pulls
in Atspi, so it can be used by both the orca runtime and the standalone
gsettings_import_export tool.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any

from gi.repository import Gio, GLib

VOICE_TYPES: list[str] = ["default", "uppercase", "hyperlink", "system"]

# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
VOICE_MIGRATION_MAP: dict[str, tuple[str, str, Any]] = {
    "established": ("established", "b", False),
    "rate": ("rate", "i", 50),
    "average-pitch": ("pitch", "d", 5.0),
    "gain": ("volume", "d", 10.0),
}

# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
VOICE_FAMILY_FIELDS: dict[str, str] = {
    "name": "family-name",
    "lang": "family-lang",
    "dialect": "family-dialect",
    "gender": "family-gender",
    "variant": "family-variant",
}

# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
LEGACY_KEY_ALIASES: dict[str, str] = {
    "progressBarVerbosity": "progressBarSpeechVerbosity",
    "progressBarUpdateInterval": "progressBarSpeechInterval",
}

# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
REVERSE_LEGACY_KEY_ALIASES: dict[str, str] = {v: k for k, v in LEGACY_KEY_ALIASES.items()}

# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
KEYBINDINGS_METADATA_KEYS: frozenset[str] = frozenset({"keyboardLayout", "orcaModifierKeys"})


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
@dataclass
class SettingsMapping:
    """Describes a mapping between a preferences key and a GSettings key."""

    migration_key: str
    gs_key: str
    gtype: str  # "b", "s", "i", "d", "as"
    default: Any
    enum_map: dict[int, str] | None = None
    string_enum: bool = False


def sanitize_gsettings_path(name: str) -> str:
    """Sanitize a name for use in a GSettings path."""

    sanitized = name.lower()
    sanitized = re.sub(r"[^a-z0-9-]", "-", sanitized)
    sanitized = re.sub(r"-+", "-", sanitized)
    return sanitized.strip("-")


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def resolve_enum_nick(value: Any, enum_map: dict[int, str]) -> str | None:
    """Resolve a JSON enum value (string nick, int, or bool) to a GSettings enum nick."""

    if isinstance(value, str):
        return value if value in enum_map.values() else None
    return enum_map.get(value)


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
_NAVIGATION_ENABLED_KEYS = (
    "caretNavigationEnabled",
    "structuralNavigationEnabled",
    "tableNavigationEnabled",
)


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def force_navigation_enabled(json_dict: dict) -> None:
    """Force navigation-enabled keys to True during migration."""

    # In the old system these defaulted to False because per-script logic controlled
    # activation. In the new system they represent the user's preference and default
    # to True. Migrating the old False would disable navigation for all scripts.
    for key in _NAVIGATION_ENABLED_KEYS:
        if key in json_dict:
            json_dict[key] = True


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def fix_bool_enum_values(json_dict: dict) -> None:
    """Fix enum settings stored as booleans instead of integers in JSON."""

    # Some enum-typed settings are booleans in user JSON files instead of the
    # expected integers. Python's bool-is-int means False==0 and True==1,
    # which silently resolve to the wrong enum nick during migration.
    bool_enum_fixes: dict[str, dict[bool, int]] = {
        "findResultsVerbosity": {True: 2, False: 2},  # FIND_SPEAK_ALL
    }
    for key, mapping in bool_enum_fixes.items():
        if key in json_dict and isinstance(json_dict[key], bool):
            json_dict[key] = mapping[json_dict[key]]


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def apply_legacy_aliases(json_dict: dict) -> None:
    """Copy legacy key names to their modern equivalents if modern key is absent."""

    for legacy_key, modern_key in LEGACY_KEY_ALIASES.items():
        if legacy_key in json_dict and modern_key not in json_dict:
            json_dict[modern_key] = json_dict[legacy_key]


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def add_legacy_aliases(result: dict) -> None:
    """Add legacy key names to exported dict for backwards compatibility with stable."""

    for modern_key, legacy_key in REVERSE_LEGACY_KEY_ALIASES.items():
        if modern_key in result:
            result[legacy_key] = result[modern_key]


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def hoist_keybindings_metadata(profile_prefs: dict) -> None:
    """Move keyboardLayout and orcaModifierKeys from keybindings to profile level."""

    keybindings = profile_prefs.get("keybindings", {})
    for key in KEYBINDINGS_METADATA_KEYS:
        if key in keybindings and key not in profile_prefs:
            profile_prefs[key] = keybindings[key]


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def sink_keybindings_metadata(profile_data: dict, keybindings: dict) -> None:
    """Move keyboardLayout and orcaModifierKeys from profile level into keybindings."""

    for key in KEYBINDINGS_METADATA_KEYS:
        if key in profile_data:
            keybindings[key] = profile_data.pop(key)


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
DESKTOP_MODIFIER_KEYS_DEFAULT: list[str] = ["Insert", "KP_Insert"]
LAPTOP_MODIFIER_KEYS_DEFAULT: list[str] = ["Caps_Lock", "Shift_Lock"]


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def populate_per_layout_modifier_keys(
    gs: Gio.Settings,
    profile_prefs: dict,
    skip_defaults: bool,
) -> bool:
    """Populates keyboard-layout and modifier keys from keybinding metadata during migration."""

    layout = profile_prefs.get("keyboardLayout", 1)
    is_desktop = layout == 1
    wrote_any = False

    layout_nick = "desktop" if is_desktop else "laptop"
    if not skip_defaults or layout_nick != "desktop":
        gs.set_string("keyboard-layout", layout_nick)
        wrote_any = True

    modifier_keys = profile_prefs.get("orcaModifierKeys")
    if modifier_keys is None:
        return wrote_any

    if is_desktop:
        key = "desktop-modifier-keys"
        default = DESKTOP_MODIFIER_KEYS_DEFAULT
    else:
        key = "laptop-modifier-keys"
        default = LAPTOP_MODIFIER_KEYS_DEFAULT

    if skip_defaults and modifier_keys == default:
        return wrote_any

    gs.set_strv(key, modifier_keys)
    return True


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def _write_one_mapping(
    gs: Gio.Settings,
    m: SettingsMapping,
    value: Any,
    skip_defaults: bool,
) -> bool:
    """Write a single mapping value to GSettings. Returns True if written."""

    if m.enum_map is not None:
        nick = resolve_enum_nick(value, m.enum_map)
        if nick is None:
            return False
        if skip_defaults and m.default in (nick, value):
            return False
        gs.set_string(m.gs_key, nick)
        return True

    if skip_defaults and value == m.default:
        return False
    if m.gtype == "b":
        gs.set_boolean(m.gs_key, value)
    elif m.gtype == "s":
        gs.set_string(m.gs_key, value)
    elif m.gtype == "i":
        gs.set_int(m.gs_key, int(value))
    elif m.gtype == "d":
        gs.set_double(m.gs_key, float(value))
    elif m.gtype == "as":
        gs.set_strv(m.gs_key, value)
    else:
        return False
    return True


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def json_to_gsettings(
    json_dict: dict,
    gs: Gio.Settings,
    mappings: list[SettingsMapping],
    skip_defaults: bool = True,
) -> bool:
    """Writes JSON settings to a Gio.Settings object. Returns True if any value was written."""

    wrote_any = False
    for m in mappings:
        if m.migration_key not in json_dict:
            continue
        if _write_one_mapping(gs, m, json_dict[m.migration_key], skip_defaults):
            wrote_any = True
    return wrote_any


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def gsettings_to_json(gs: Gio.Settings, mappings: list[SettingsMapping]) -> dict:
    """Reads explicitly-set GSettings values into a JSON-compatible dict."""

    result: dict[str, Any] = {}
    for m in mappings:
        user_value = gs.get_user_value(m.gs_key)
        if user_value is None:
            continue
        if m.enum_map is not None:
            gs_str = user_value.get_string()
            if m.string_enum:
                result[m.migration_key] = gs_str
            else:
                reverse_map = {v: k for k, v in m.enum_map.items()}
                json_value = reverse_map.get(gs_str)
                if json_value is not None:
                    result[m.migration_key] = json_value
        elif m.gtype == "b":
            result[m.migration_key] = user_value.get_boolean()
        elif m.gtype == "s":
            result[m.migration_key] = user_value.get_string()
        elif m.gtype == "i":
            result[m.migration_key] = user_value.get_int32()
        elif m.gtype == "d":
            result[m.migration_key] = user_value.get_double()
        elif m.gtype == "as":
            result[m.migration_key] = list(user_value.unpack())
    return result


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def import_voice(gs: Gio.Settings, voice_data: dict, skip_defaults: bool = True) -> bool:
    """Import ACSS voice data to a Gio.Settings object. Returns True if any value was written."""

    migrated = False
    for acss_key, (gs_key, gs_type, default) in VOICE_MIGRATION_MAP.items():
        if acss_key not in voice_data:
            continue
        value = voice_data[acss_key]
        if skip_defaults:
            if gs_type == "b" and bool(value) == default:
                continue
            if gs_type == "i" and int(value) == default:
                continue
            if gs_type == "d" and float(value) == default:
                continue
        if gs_type == "b":
            gs.set_boolean(gs_key, bool(value))
        elif gs_type == "i":
            gs.set_int(gs_key, int(value))
        elif gs_type == "d":
            gs.set_double(gs_key, float(value))
        migrated = True

    family = voice_data.get("family", {})
    if isinstance(family, dict):
        for json_field, gs_key in VOICE_FAMILY_FIELDS.items():
            val = family.get(json_field)
            if val is not None and str(val):
                gs.set_string(gs_key, str(val))
                migrated = True

    return migrated


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def export_voice(gs: Gio.Settings) -> dict:
    """Export voice settings from a Gio.Settings to ACSS format."""

    voice_data: dict[str, Any] = {}
    for acss_key, (gs_key, gs_type, _default) in VOICE_MIGRATION_MAP.items():
        user_value = gs.get_user_value(gs_key)
        if user_value is None:
            continue
        if gs_type == "b":
            voice_data[acss_key] = user_value.get_boolean()
        elif gs_type == "i":
            voice_data[acss_key] = user_value.get_int32()
        elif gs_type == "d":
            voice_data[acss_key] = user_value.get_double()

    family_dict: dict[str, str] = {}
    for json_field, gs_key in VOICE_FAMILY_FIELDS.items():
        user_value = gs.get_user_value(gs_key)
        if user_value is not None:
            val = user_value.get_string()
            if val:
                family_dict[json_field] = val
    if family_dict:
        voice_data["family"] = family_dict

    return voice_data


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def import_synthesizer(gs: Gio.Settings, profile_prefs: dict) -> bool:
    """Import speechServerInfo to GSettings. Returns True if written."""

    speech_server_info = profile_prefs.get("speechServerInfo")
    if speech_server_info is None or len(speech_server_info) < 2:
        return False
    server_name = speech_server_info[0]
    synthesizer = speech_server_info[1]
    if server_name is None and synthesizer is None:
        return False
    gs.set_string("speech-server", server_name or "")
    gs.set_string("synthesizer", synthesizer or "")
    return True


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def export_synthesizer(gs: Gio.Settings) -> tuple[str, str] | None:
    """Export synthesizer from GSettings. Returns (display_name, module_id) or None."""

    synth_value = gs.get_user_value("synthesizer")
    if synth_value is None:
        return None
    synth = synth_value.get_string()
    server_value = gs.get_user_value("speech-server")
    server_name = server_value.get_string() if server_value is not None else ""
    if synth:
        return (server_name, synth)
    return ("", "")


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def import_pronunciations(gs: Gio.Settings, pronunciations_dict: dict) -> bool:
    """Import pronunciation dictionary to GSettings. Returns True if written.

    JSON format: {word: [word, replacement]} or {word: replacement}
    GSettings format: a{ss} {word: replacement}
    """

    converted: dict[str, str] = {}
    for key, value in pronunciations_dict.items():
        if isinstance(value, list) and len(value) >= 2:
            converted[key] = value[1]
        elif isinstance(value, list) and len(value) == 1:
            converted[key] = value[0]
        elif isinstance(value, str):
            converted[key] = value
    if not converted:
        return False

    gs.set_value("entries", GLib.Variant("a{ss}", converted))
    return True


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def export_pronunciations(gs: Gio.Settings) -> dict:
    """Export pronunciation dictionary from GSettings to JSON format.

    GSettings format: a{ss} {word: replacement}
    JSON format: {word: [word, replacement]}
    """

    user_value = gs.get_user_value("entries")
    if user_value is None:
        return {}
    entries = user_value.unpack()
    result: dict[str, list[str]] = {}
    for word, replacement in entries.items():
        result[word] = [word, replacement]
    return result


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def import_keybindings(gs: Gio.Settings, keybindings_dict: dict) -> bool:
    """Import keybinding overrides to GSettings. Returns True if written.

    JSON format: {command_name: [[keysym, mask, mods, clicks], ...]}
    GSettings format: a{saas} (same structure, all strings)
    """

    converted: dict[str, list[list[str]]] = {}
    for key, value in keybindings_dict.items():
        if key in KEYBINDINGS_METADATA_KEYS:
            continue
        if isinstance(value, list):
            bindings: list[list[str]] = [
                [str(v) for v in binding] for binding in value if isinstance(binding, list)
            ]
            converted[key] = bindings
    if not converted:
        return False

    gs.set_value("entries", GLib.Variant("a{saas}", converted))
    return True


# TODO - JD: Delete this in v52 (remove -i/--import-dir support).
def export_keybindings(gs: Gio.Settings) -> dict:
    """Export keybinding overrides from GSettings to JSON format."""

    user_value = gs.get_user_value("entries")
    if user_value is None:
        return {}
    return user_value.unpack()
