#!/usr/bin/env python
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>

import os
import re
import socket
import sys
import termios
import time
from collections.abc import Callable, Iterator, Sequence
from contextlib import suppress
from functools import partial
from pprint import pformat
from typing import IO, TYPE_CHECKING, Protocol, cast

from kittens.tui.operations import colored, styled

from .child import cmdline_of_pid
from .cli import version
from .colors import theme_colors
from .constants import extensions_dir, is_macos, is_wayland, kitty_base_dir, kitty_exe, shell_path
from .fast_data_types import Color, SingleKey, current_fonts, glfw_get_system_color_theme, gpu_driver_version_string, num_users, wayland_compositor_data
from .options.types import Options as KittyOpts
from .options.types import defaults, secret_options
from .options.utils import KeyboardMode, KeyDefinition
from .rgb import color_as_sharp, color_from_int
from .types import Shortcut, mod_to_names
from .utils import shlex_split

Print = Callable[..., None]
ShortcutMap = dict[Shortcut, str]
if TYPE_CHECKING:
    from .fast_data_types import FontFace
else:
    FontFace = object


def green(x: str) -> str:
    return colored(x, 'green')


def yellow(x: str) -> str:
    return colored(x, 'yellow')


def title(x: str) -> str:
    return colored(x, 'blue', intense=True)


def print_event(ev: str, defn: str, print: Print) -> None:
    print(f'\t{ev} →  {defn}')


def print_mapping_changes(defns: dict[str, str], changes: set[str], text: str, print: Print) -> None:
    if changes:
        print(title(text))
        for k in sorted(changes):
            print_event(k, defns[k], print)

class AnyEvent(Protocol):
    def human_repr(self, kitty_mod: int = 0) -> str: ...


def compare_maps[T: AnyEvent](
    final: dict[T, str], final_kitty_mod: int, initial: dict[T, str],
    initial_kitty_mod: int, print: Print, mode_name: str = ''
) -> None:
    ei = {k.human_repr(initial_kitty_mod): v for k, v in initial.items()}
    ef = {k.human_repr(final_kitty_mod): v for k, v in final.items()}
    added = set(ef) - set(ei)
    removed = set(ei) - set(ef)
    changed = {k for k in set(ef) & set(ei) if ef[k] != ei[k]}
    which = 'shortcuts' if isinstance(next(iter(initial or final)), Shortcut) else 'mouse actions'
    if mode_name and (added or removed or changed):
        print(f'{title("Changes in keyboard mode: " + mode_name)}')
    print_mapping_changes(ef, added, f'Added {which}:', print)
    print_mapping_changes(ei, removed, f'Removed {which}:', print)
    print_mapping_changes(ef, changed, f'Changed {which}:', print)



def compare_opts(opts: KittyOpts, global_shortcuts: dict[str, SingleKey] | None, print: Print) -> None:
    from .config import load_config
    print()
    print('Config options different from defaults:')
    default_opts = load_config()
    ignored = ('keymap', 'sequence_map', 'mousemap', 'map', 'mouse_map')
    changed_opts = [
        f for f in sorted(defaults._fields)
        if f not in ignored and getattr(opts, f) != getattr(defaults, f)
    ]
    field_len = max(map(len, changed_opts)) if changed_opts else 20
    fmt = f'{{:{field_len:d}s}}'
    colors = []
    for f in changed_opts:
        if f in secret_options:
            print(title(f'{f}:'), 'REDACTED FOR SECURITY')
            continue
        val = getattr(opts, f)
        if isinstance(val, dict):
            print(f'{f}:')
            if f == 'symbol_map':
                for k in sorted(val):
                    print(f'\tU+{k[0]:04x} - U+{k[1]:04x} → {val[k]}')
            elif f == 'modify_font':
                for k in sorted(val):
                    print('   ', val[k])
            elif f == 'exe_search_path':
                for k in val:
                    print('   ', k)
            else:
                print(pformat(val))
        else:
            val = getattr(opts, f)
            if isinstance(val, Color):
                colors.append(fmt.format(f) + ' ' + color_as_sharp(val) + ' ' + styled('  ', bg=val))
            else:
                if f == 'kitty_mod':
                    print(fmt.format(f), '+'.join(mod_to_names(getattr(opts, f))))
                elif f in ('wayland_titlebar_color', 'macos_titlebar_color'):
                    if val == 0:
                        cval = 'system'
                    elif val == 1:
                        cval = 'background'
                    else:
                        col = color_from_int(val >> 8)
                        cval = color_as_sharp(col) + ' ' + styled('  ', bg=col)
                    colors.append(fmt.format(f) + ' ' + cval)
                else:
                    print(fmt.format(f), str(getattr(opts, f)))

    compare_maps(opts.mousemap, opts.kitty_mod, default_opts.mousemap, default_opts.kitty_mod, print)

    def as_str(defns: Sequence[KeyDefinition]) -> str:
        seen = set()
        uniq = []
        for d in reversed(defns):
            key = d.unique_identity_within_keymap
            if key not in seen:
                seen.add(key)
                uniq.append(d)
        return ', '.join(d.human_repr() for d in uniq)

    def build_shortcut_map(km: KeyboardMode) -> ShortcutMap:
        result: ShortcutMap = {}
        for defns in km.keymap.values():
            by_seq: dict[tuple[SingleKey, ...], list[KeyDefinition]] = {}
            for d in defns:
                by_seq.setdefault(d.full_key_sequence_to_trigger, []).append(d)
            for seq, seq_defns in by_seq.items():
                result[Shortcut(seq)] = as_str(seq_defns)
        return result

    for kmn, initial_ in default_opts.keyboard_modes.items():
        initial = build_shortcut_map(initial_)
        final_ = opts.keyboard_modes.get(kmn, KeyboardMode(kmn))
        final = build_shortcut_map(final_)
        if not kmn and global_shortcuts:
            for action, sk in global_shortcuts.items():
                sc = Shortcut((sk,))
                if sc not in final:
                    final[sc] = action
        compare_maps(final, opts.kitty_mod, initial, default_opts.kitty_mod, print, mode_name=kmn)
    new_keyboard_modes = set(opts.keyboard_modes) - set(default_opts.keyboard_modes)
    for kmn in new_keyboard_modes:
        initial_ = KeyboardMode(kmn)
        initial = build_shortcut_map(initial_)
        final_ = opts.keyboard_modes[kmn]
        final = build_shortcut_map(final_)
        compare_maps(final, opts.kitty_mod, initial, default_opts.kitty_mod, print, mode_name=kmn)
    if colors:
        print(f'{title("Colors")}:', end='\n\t')
        print('\n\t'.join(sorted(colors)))


class IssueData:

    def __init__(self) -> None:
        self.uname = os.uname()
        self.s, self.n, self.r, self.v, self.m = self.uname
        try:
            self.hostname = self.o = socket.gethostname()
        except Exception:
            self.hostname = self.o = 'localhost'
        _time = time.localtime()
        self.formatted_time = self.d = time.strftime('%a %b %d %Y', _time)
        self.formatted_date = self.t = time.strftime('%H:%M:%S', _time)
        try:
            self.tty_name = format_tty_name(os.ctermid())
        except OSError:
            self.tty_name = '(none)'
        self.l = self.tty_name
        self.baud_rate = 0
        if sys.stdin.isatty():
            with suppress(OSError):
                self.baud_rate = termios.tcgetattr(sys.stdin.fileno())[5]
        self.b = str(self.baud_rate)
        try:
            self.num_users = num_users()
        except RuntimeError:
            self.num_users = -1
        self.u = str(self.num_users)
        self.U = self.u + ' user' + ('' if self.num_users == 1 else 's')
        self.vars = {}
        with suppress(Exception), open('/etc/os-release') as osf:
            for line in osf:
                k, _, v = line.strip().partition('=')
                self.vars[k] = ' '.join(shlex_split(v))
        if not self.vars:
            with suppress(Exception), open('/usr/lib/os-release') as osf:
                for line in osf:
                    k, _, v = line.strip().partition('=')
                    self.vars[k] = ' '.join(shlex_split(v))

    def translate_issue_char(self, char: str) -> str:
        try:
            return str(getattr(self, char)) if len(char) == 1 else char
        except AttributeError:
            return char

    def parse_issue_file(self, issue_file: IO[str]) -> Iterator[str]:
        state = 'normal'
        varname = ''
        for ch in issue_file.read():
            match state:
                case 'normal':
                    if ch == '\\':
                        state = 'escape'
                    else:
                        yield ch
                case 'escape':
                    match ch:
                        case 'S':
                            state = 'sub_start'
                        case '\\':
                            yield '\\'
                            state = 'normal'
                        case _:
                            yield self.translate_issue_char(ch)
                            state = 'normal'
                case 'sub_start':
                    if ch == '{':  # }
                        state = 'sub'
                        varname = ''
                    else:
                        yield ch
                        state = 'normal'
                case 'sub':
                    if ch == '}':
                        if val := self.vars.get(varname):
                            yield val
                        state = 'normal'
                    else:
                        varname += ch


def format_tty_name(raw: str) -> str:
    return re.sub(r'^/dev/([^/]+)/([^/]+)$', r'\1\2', raw)


def compositor_name() -> str:
    ans = 'X11'
    if is_wayland():
        ans = 'Wayland'
        with suppress(Exception):
            pid, missing_capabilities = wayland_compositor_data()
            if pid > -1:
                cmdline = cmdline_of_pid(pid)
                exe = cmdline[0]
                with suppress(Exception):
                    import subprocess
                    if exe.lower() == 'hyprland':
                        raw = subprocess.check_output(['hyprctl', 'version']).decode().strip()
                        m = re.search(r'^Tag:\s*(\S+)', raw, flags=re.M)
                        if m is not None:
                            exe = f'{exe} {m.group(1)}'
                        else:
                            exe = raw.splitlines()[0]
                    exe = subprocess.check_output([exe, '--version']).decode().strip().splitlines()[0]
                ans += f' ({exe})'
            if missing_capabilities:
                ans += f' missing: {missing_capabilities}'
    return ans


def issue_data() -> str:
    with suppress(Exception):
        idata = IssueData()
        with open('/etc/issue', encoding='utf-8', errors='replace') as f:
            return ''.join(idata.parse_issue_file(f)).strip()
    return ''


def debug_config(opts: KittyOpts | None = None, global_shortcuts: dict[str, SingleKey] | None = None) -> str:
    if opts is None:
        from kitty.cli import create_default_opts
        opts = create_default_opts()
    from io import StringIO
    out = StringIO()
    p = partial(print, file=out)
    p(version(add_rev=True))
    p(' '.join(os.uname()))
    if is_macos:
        import subprocess
        with suppress(Exception):
            p(' '.join(subprocess.check_output(['sw_vers']).decode('utf-8').splitlines()).strip())
    if (idata := issue_data()):
        p(idata)
    with suppress(Exception), open('/etc/lsb-release', encoding='utf-8', errors='replace') as f:
        p(f.read().strip())
    if not is_macos:
        p('Running under:', green(compositor_name()))
    p(green('OpenGL:'), gpu_driver_version_string())
    p(green('Frozen:'), 'True' if getattr(sys, 'frozen', False) else 'False')
    p(green('Fonts:'))
    for k, font in current_fonts().items():
        if hasattr(font, 'identify_for_debug'):
            face: FontFace = cast(FontFace, font)
            flines = face.identify_for_debug().splitlines()
            p(yellow(f'{k.rjust(8)}:'), flines[0])
            for fl in flines[1:]:
                p(' ' * 9, fl)
    p(green('Paths:'))
    p(yellow('  kitty:'), os.path.realpath(kitty_exe()))
    p(yellow('  base dir:'), kitty_base_dir)
    p(yellow('  extensions dir:'), extensions_dir)
    p(yellow('  system shell:'), shell_path)
    p(f'System color scheme: {green(glfw_get_system_color_theme())}. Applied color theme type: {yellow(theme_colors.applied_theme or "none")}')
    if opts.config_paths:
        p(green('Loaded config files:'))
        p(' ', '\n  '.join(opts.config_paths))
    if opts.config_overrides:
        p(green('Loaded config overrides:'))
        p(' ', '\n  '.join(opts.config_overrides))
    compare_opts(opts, global_shortcuts, p)
    p()
    p(green('Important environment variables seen by the kitty process:'))

    def penv(k: str) -> None:
        v = os.environ.get(k)
        if v is not None:
            p('\t' + k.ljust(35), styled(v, dim=True))

    for k in (
        'PATH LANG KITTY_CONFIG_DIRECTORY KITTY_CACHE_DIRECTORY VISUAL EDITOR SHELL'
        ' GLFW_IM_MODULE KITTY_WAYLAND_DETECT_MODIFIERS DISPLAY WAYLAND_DISPLAY USER XCURSOR_SIZE'
    ).split():
        penv(k)
    for k in os.environ:
        if k.startswith('LC_') or k.startswith('XDG_'):
            penv(k)
    return out.getvalue()
