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


import enum
import re
import sys
from collections import defaultdict
from collections.abc import Callable, Container, Iterable, Iterator, Sequence
from contextlib import suppress
from dataclasses import dataclass, fields
from functools import lru_cache
from typing import (
    Any,
    Generic,
    Literal,
    NamedTuple,
    TypeVar,
    cast,
    get_args,
)

import kitty.fast_data_types as defines
from kitty.conf.utils import (
    CurrentlyParsing,
    KeyAction,
    KeyFuncWrapper,
    currently_parsing,
    number_with_unit,
    percent,
    positive_float,
    positive_int,
    python_string,
    to_bool,
    to_cmdline,
    to_color,
    uniq,
    unit_float,
)
from kitty.constants import is_macos
from kitty.fast_data_types import CURSOR_BEAM, CURSOR_BLOCK, CURSOR_HOLLOW, CURSOR_UNDERLINE, NO_CURSOR_SHAPE, Color, Shlex, SingleKey
from kitty.fonts import FontModification, FontSpec, ModificationType, ModificationUnit, ModificationValue
from kitty.key_names import character_key_name_aliases, functional_key_name_aliases, get_key_name_lookup
from kitty.rgb import color_as_int
from kitty.types import FloatEdges, MouseEvent
from kitty.utils import expandvars, log_error, resolve_abs_or_config_path, shlex_split

KeyMap = dict[SingleKey, list['KeyDefinition']]
MouseMap = dict[MouseEvent, str]
KeySequence = tuple[SingleKey, ...]
MINIMUM_FONT_SIZE = 4
default_tab_separator = ' ┇'
mod_map = {'⌃': 'CONTROL', 'CTRL': 'CONTROL', '⇧': 'SHIFT', '⌥': 'ALT', 'OPTION': 'ALT', 'OPT': 'ALT',
           '⌘': 'SUPER', 'COMMAND': 'SUPER', 'CMD': 'SUPER', 'KITTY_MOD': 'KITTY'}
character_key_name_aliases_with_ascii_lowercase: dict[str, str] = character_key_name_aliases.copy()
for x in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
    character_key_name_aliases_with_ascii_lowercase[x] = x.lower()
sequence_sep = '>'
mouse_button_map = {'left': 'b1', 'middle': 'b3', 'right': 'b2'}
mouse_trigger_count_map = {'doubleclick': -3, 'click': -2, 'release': -1, 'press': 1, 'doublepress': 2, 'triplepress': 3}
FuncArgsType = tuple[str, Sequence[Any]]
func_with_args = KeyFuncWrapper[FuncArgsType]()
DELETE_ENV_VAR = '_delete_this_env_var_'


class MapType(enum.Enum):
    MAP = 'map'
    MOUSE_MAP = 'mouse_map'
    OPEN_ACTION = 'open_action'


class InvalidMods(ValueError):
    pass


# Actions {{{
@func_with_args(
    'pass_selection_to_program', 'new_window', 'new_tab', 'new_os_window',
    'new_window_with_cwd', 'new_tab_with_cwd', 'new_os_window_with_cwd',
    'launch', 'mouse_handle_click', 'show_error',
    )
def shlex_parse(func: str, rest: str) -> FuncArgsType:
    return func, to_cmdline(rest)


def parse_send_text_bytes(text: str) -> bytes:
    return defines.expand_ansi_c_escapes(text).encode('utf-8')


@func_with_args('scroll_prompt_to_top')
def scroll_prompt_to_top(func: str, rest: str) -> FuncArgsType:
    return func, [to_bool(rest) if rest else False]


@func_with_args('send_text')
def send_text_parse(func: str, rest: str) -> FuncArgsType:
    args = rest.split(maxsplit=1)
    mode = ''
    data = b''
    if len(args) > 1:
        mode = args[0]
        try:
            data = parse_send_text_bytes(args[1])
        except Exception:
            log_error('Ignoring invalid send_text string: ' + args[1])
    return func, [mode, data]


@func_with_args('send_key')
def send_key(func: str, rest: str) -> FuncArgsType:
    return func, rest.split()


@func_with_args('run_kitten', 'run_simple_kitten', 'kitten')
def kitten_parse(func: str, rest: str) -> FuncArgsType:
    parts = to_cmdline(rest)
    if func == 'kitten':
        return func, parts
    return 'kitten', parts[1:]


@func_with_args('open_url')
def open_url_parse(func: str, rest: str) -> FuncArgsType:
    from urllib.parse import urlparse
    url = ''
    try:
        url = python_string(rest)
        tokens = urlparse(url)
        if not all((tokens.scheme, tokens.netloc,)):
            raise ValueError('Invalid URL')
    except Exception:
        log_error('Ignoring invalid URL string: ' + rest)
    return func, (url,)


@func_with_args('goto_tab')
def goto_tab_parse(func: str, rest: str) -> FuncArgsType:
    n = int(rest)
    if n < 0:
        n += 1  # goto_tab subtracts 1 from its argument, this maps both zero and -1 to previous tab for backwards compat.
    return func, (n,)


@func_with_args('detach_window')
def detach_window_parse(func: str, rest: str) -> FuncArgsType:
    if rest not in ('new', 'new-tab', 'new-tab-left', 'new-tab-right', 'ask', 'tab-prev', 'tab-left', 'tab-right'):
        log_error(f'Ignoring invalid detach_window argument: {rest}')
        rest = 'new'
    return func, (rest,)


@func_with_args('close_window_with_confirmation')
def close_window_with_confirmation(func: str, rest: str) -> FuncArgsType:
    ignore_shell = rest == 'ignore-shell'
    return func, (ignore_shell,)


@func_with_args('detach_tab')
def detach_tab_parse(func: str, rest: str) -> FuncArgsType:
    if rest not in ('new', 'ask'):
        log_error(f'Ignoring invalid detach_tab argument: {rest}')
        rest = 'new'
    return func, (rest,)


@func_with_args(
    'set_background_opacity', 'goto_layout', 'toggle_layout', 'toggle_tab', 'kitty_shell', 'show_kitty_doc',
    'set_tab_title', 'push_keyboard_mode', 'dump_lines_with_attrs', 'set_window_title', 'simulate_color_scheme_preference_change',
)
def simple_parse(func: str, rest: str) -> FuncArgsType:
    return func, (rest,)


@func_with_args('set_font_size')
def float_parse(func: str, rest: str) -> FuncArgsType:
    return func, (float(rest),)


@func_with_args('signal_child')
def signal_child_parse(func: str, rest: str) -> FuncArgsType:
    import signal
    signals = []
    for q in rest.split():
        try:
            signum = getattr(signal, q.upper())
        except AttributeError:
            log_error(f'Unknown signal: {rest} ignoring')
        else:
            signals.append(signum)
    return func, tuple(signals)


@func_with_args('change_font_size')
def parse_change_font_size(func: str, rest: str) -> tuple[str, tuple[bool, str | None, float]]:
    vals = rest.strip().split(maxsplit=1)
    if len(vals) != 2:
        log_error(f'Invalid change_font_size specification: {rest}, treating it as default')
        return func, (True, None, 0)
    c_all = vals[0].lower() == 'all'
    sign: str | None = None
    amt = vals[1]
    if amt[0] in '+-*/':
        sign = amt[0]
        amt = amt[1:]
    return func, (c_all, sign, float(amt.strip()))


@func_with_args('clear_terminal')
def clear_terminal(func: str, rest: str) -> FuncArgsType:
    vals = rest.strip().split(maxsplit=1)
    if len(vals) != 2:
        log_error('clear_terminal needs two arguments, using defaults')
        args = ['reset', True]
    else:
        action = vals[0].lower()
        if action not in ('reset', 'scroll', 'scrollback', 'clear', 'to_cursor', 'to_cursor_scroll'):
            log_error(f'{action} is unknown for clear_terminal, using reset')
            action = 'reset'
        args = [action, vals[1].lower() == 'active']
    return func, args


@func_with_args('copy_to_buffer')
def copy_to_buffer(func: str, rest: str) -> FuncArgsType:
    return func, [rest]


@func_with_args('paste_from_buffer')
def paste_from_buffer(func: str, rest: str) -> FuncArgsType:
    return func, [rest]


@func_with_args('paste')
def paste_parse(func: str, rest: str) -> FuncArgsType:
    text = ''
    try:
        text = defines.expand_ansi_c_escapes(rest)
    except Exception:
        log_error('Ignoring invalid paste string: ' + rest)
    return func, [text]


@func_with_args('neighboring_window')
def neighboring_window(func: str, rest: str) -> FuncArgsType:
    rest = rest.lower()
    rest = {'up': 'top', 'down': 'bottom'}.get(rest, rest)
    if rest not in ('left', 'right', 'top', 'bottom'):
        log_error(f'Invalid neighbor specification: {rest}')
        rest = 'right'
    return func, [rest]


@func_with_args('resize_window')
def resize_window(func: str, rest: str) -> FuncArgsType:
    vals = rest.strip().split(maxsplit=1)
    if len(vals) > 2:
        log_error('resize_window needs one or two arguments, using defaults')
        args = ['wider', 1]
    else:
        quality = vals[0].lower()
        if quality not in ('reset', 'taller', 'shorter', 'wider', 'narrower'):
            log_error(f'Invalid quality specification: {quality}')
            quality = 'wider'
        increment = 1
        if len(vals) == 2:
            try:
                increment = int(vals[1])
            except Exception:
                log_error(f'Invalid increment specification: {vals[1]}')
        args = [quality, increment]
    return func, args


@func_with_args('move_window')
def move_window(func: str, rest: str) -> FuncArgsType:
    rest = rest.lower()
    rest = {'up': 'top', 'down': 'bottom'}.get(rest, rest)
    prest: int | str = rest
    try:
        prest = int(prest)
    except Exception:
        if prest not in ('left', 'right', 'top', 'bottom'):
            log_error(f'Invalid move_window specification: {rest}')
            prest = 0
    return func, [prest]


@func_with_args('pipe')
def pipe(func: str, rest: str) -> FuncArgsType:
    r = list(shlex_split(rest))
    if len(r) < 3:
        log_error('Too few arguments to pipe function')
        r = ['none', 'none', 'true']
    return func, r


@func_with_args('set_colors')
def set_colors(func: str, rest: str) -> FuncArgsType:
    r = list(shlex_split(rest))
    if len(r) < 1:
        log_error('Too few arguments to set_colors function')
    return func, r


@func_with_args('remote_control')
def remote_control(func: str, rest: str) -> FuncArgsType:
    func, args = shlex_parse(func, rest)
    if len(args) < 1:
        log_error('Too few arguments to remote_control function')
    return func, args


@func_with_args('remote_control_script')
def remote_control_script(func: str, rest: str) -> FuncArgsType:
    func, args = shlex_parse(func, rest)
    if len(args) < 1:
        log_error('Too few arguments to remote_control_script function')
    return func, args


@func_with_args('nth_os_window', 'nth_window', 'scroll_to_prompt', 'visual_window_select_action_trigger', 'next_layout')
def single_integer_arg(func: str, rest: str) -> FuncArgsType:
    try:
        num = int(rest)
    except Exception:
        if rest:
            log_error(f'Invalid number for {func}: {rest}')
        num = -1 if func == 'scroll_to_prompt' else 1
    return func, [num]


@func_with_args('sleep')
def sleep(func: str, sleep_time: str) -> FuncArgsType:
    mult = 1
    sleep_time = sleep_time or '1'
    if sleep_time[-1] in 'shmd':
        mult = {'s': 1, 'm': 60, 'h': 3600, 'd': 24 * 3600}[sleep_time[-1]]
        sleep_time = sleep_time[:-1]
    return func, [abs(float(sleep_time)) * mult]


@func_with_args('disable_ligatures_in')
def disable_ligatures_in(func: str, rest: str) -> FuncArgsType:
    parts = rest.split(maxsplit=1)
    if len(parts) == 1:
        where, strategy = 'active', parts[0]
    else:
        where, strategy = parts
    if where not in ('active', 'all', 'tab'):
        raise ValueError(f'{where} is not a valid set of windows to disable ligatures in')
    if strategy not in ('never', 'always', 'cursor'):
        raise ValueError(f'{strategy} is not a valid disable ligatures strategy')
    return func, [where, strategy]


@func_with_args('layout_action')
def layout_action(func: str, rest: str) -> FuncArgsType:
    parts = rest.split(maxsplit=1)
    if not parts:
        raise ValueError('layout_action must have at least one argument')
    return func, [parts[0], tuple(parts[1:])]


def parse_marker_spec(ftype: str, parts: Sequence[str]) -> tuple[str, str | tuple[tuple[int, str], ...], int]:
    flags = re.UNICODE
    if ftype in ('text', 'itext', 'regex', 'iregex'):
        if ftype.startswith('i'):
            flags |= re.IGNORECASE
        if not parts or len(parts) % 2 != 0:
            raise ValueError('Mark group number and text/regex are not specified in pairs: {}'.format(' '.join(parts)))
        ans = []
        for i in range(0, len(parts), 2):
            try:
                color = max(1, min(int(parts[i]), 3))
            except Exception:
                raise ValueError(f'Mark group in marker specification is not an integer: {parts[i]}')
            sspec = parts[i + 1]
            if 'regex' not in ftype:
                sspec = re.escape(sspec)
            ans.append((color, sspec))
        ftype = 'regex'
        spec: str | tuple[tuple[int, str], ...] = tuple(ans)
    elif ftype == 'function':
        spec = ' '.join(parts)
    else:
        raise ValueError(f'Unknown marker type: {ftype}')
    return ftype, spec, flags


@func_with_args('toggle_marker')
def toggle_marker(func: str, rest: str) -> FuncArgsType:
    parts = rest.split(maxsplit=1)
    if len(parts) != 2:
        raise ValueError(f'{rest} is not a valid marker specification')
    ftype, spec = parts
    parts = list(shlex_split(spec))
    return func, list(parse_marker_spec(ftype, parts))


@func_with_args('scroll_to_mark')
def scroll_to_mark(func: str, rest: str) -> FuncArgsType:
    parts = rest.split()
    if not parts or not rest:
        return func, [True, 0]
    if len(parts) == 1:
        q = parts[0].lower()
        if q in ('prev', 'previous', 'next'):
            return func, [q != 'next', 0]
        try:
            return func, [True, max(0, min(int(q), 3))]
        except Exception:
            raise ValueError(f'{rest} is not a valid scroll_to_mark destination')
    return func, [parts[0] != 'next', max(0, min(int(parts[1]), 3))]


@func_with_args('mouse_selection')
def mouse_selection(func: str, rest: str) -> FuncArgsType:
    cmap = getattr(mouse_selection, 'code_map', None)
    if cmap is None:
        cmap = {
            'normal': defines.MOUSE_SELECTION_NORMAL,
            'extend': defines.MOUSE_SELECTION_EXTEND,
            'move-end': defines.MOUSE_SELECTION_MOVE_END,
            'rectangle': defines.MOUSE_SELECTION_RECTANGLE,
            'word': defines.MOUSE_SELECTION_WORD,
            'line': defines.MOUSE_SELECTION_LINE,
            'line_from_point': defines.MOUSE_SELECTION_LINE_FROM_POINT,
            'word_and_line_from_point': defines.MOUSE_SELECTION_WORD_AND_LINE_FROM_POINT,
            'upto_surrounding_whitespace': defines.MOUSE_SELECTION_UPTO_SURROUNDING_WHITESPACE,
        }
        setattr(mouse_selection, 'code_map', cmap)
    return func, [cmap[rest]]


@func_with_args('load_config_file')
def load_config_file(func: str, rest: str) -> FuncArgsType:
    return func, list(shlex_split(rest))
# }}}


def parse_mods(parts: Iterable[str], sc: str) -> int | None:

    def map_mod(m: str) -> str:
        return mod_map.get(m, m)

    mods = 0
    for m in parts:
        try:
            mods |= getattr(defines, f'GLFW_MOD_{map_mod(m.upper())}')
        except AttributeError:
            if m.upper() != 'NONE':
                log_error(f'Shortcut: {sc} has unknown modifier, ignoring')
            return None

    return mods


def to_modifiers(val: str) -> int:
    return parse_mods(val.split('+'), val) or 0


def parse_shortcut(sc: str) -> SingleKey:
    if sc.endswith('+') and len(sc) > 1:
        sc = f'{sc[:-1]}plus'
    parts = sc.split('+')
    mods = 0
    if len(parts) > 1:
        mods = parse_mods(parts[:-1], sc) or 0
        if not mods:
            raise InvalidMods('Invalid shortcut')
    q = parts[-1]
    q = character_key_name_aliases_with_ascii_lowercase.get(q.upper(), q)
    is_native = False
    if q.startswith('0x'):
        try:
            key = int(q, 16)
        except Exception:
            key = 0
        else:
            is_native = True
    else:
        try:
            key = ord(q)
        except Exception:
            uq = q.upper()
            uq = functional_key_name_aliases.get(uq, uq)
            x: int | None = getattr(defines, f'GLFW_FKEY_{uq}', None)
            if x is None:
                lf = get_key_name_lookup()
                key = lf(q, False) or 0
                is_native = key > 0
            else:
                key = x

    return SingleKey(mods, is_native, key or 0)


def to_font_size(x: str) -> float:
    return max(MINIMUM_FONT_SIZE, float(x))


def disable_ligatures(x: str) -> int:
    cmap = {'never': 0, 'cursor': 1, 'always': 2}
    return cmap.get(x.lower(), 0)


def box_drawing_scale(x: str) -> tuple[float, float, float, float]:
    ans = tuple(float(q.strip()) for q in x.split(','))
    if len(ans) != 4:
        raise ValueError('Invalid box_drawing scale, must have four entries')
    return ans[0], ans[1], ans[2], ans[3]


def cursor_text_color(x: str) -> Color | None:
    if x.lower() == 'background':
        return None
    return to_color(x)


cshapes = {
    'block': CURSOR_BLOCK,
    'beam': CURSOR_BEAM,
    'underline': CURSOR_UNDERLINE
}
cshapes_unfocused = {
    'block': CURSOR_BLOCK,
    'beam': CURSOR_BEAM,
    'underline': CURSOR_UNDERLINE,
    'hollow': CURSOR_HOLLOW,
    'unchanged': NO_CURSOR_SHAPE,
}


def to_cursor_shape(x: str) -> int:
    try:
        return cshapes[x.lower()]
    except KeyError:
        raise ValueError(
            'Invalid cursor shape: {} allowed values are {}'.format(
                x, ', '.join(cshapes)
            )
        )


def to_cursor_unfocused_shape(x: str) -> int:
    try:
        return cshapes_unfocused[x.lower()]
    except KeyError:
        raise ValueError(
            'Invalid unfocused cursor shape: {} allowed values are {}'.format(
                x, ', '.join(cshapes_unfocused)
            )
        )

def cursor_trail_decay(x: str) -> tuple[float, float]:
    fast, slow = map(positive_float, x.split())
    slow = max(slow, fast)
    return fast, slow


def scrollback_lines(x: str) -> int:
    ans = int(x)
    if ans < 0:
        ans = 2 ** 32 - 1
    return ans


def scrollback_pager_history_size(x: str) -> int:
    ans = int(max(0, float(x)) * 1024 * 1024)
    return min(ans, 4096 * 1024 * 1024 - 1)


# "single" for backwards compat
url_style_map = {'none': 0, 'single': 1, 'straight': 1, 'double': 2, 'curly': 3, 'dotted': 4, 'dashed': 5}


def url_style(x: str) -> int:
    return url_style_map.get(x, url_style_map['curly'])


def url_prefixes(x: str) -> tuple[str, ...]:
    return tuple(a.lower() for a in x.replace(',', ' ').split())


def copy_on_select(raw: str) -> str:
    q = raw.lower()
    # boolean values special cased for backwards compat
    if q in ('y', 'yes', 'true', 'clipboard'):
        return 'clipboard'
    if q in ('n', 'no', 'false', ''):
        return ''
    return raw


def window_size(val: str) -> tuple[int, str]:
    val = val.lower()
    unit = 'cells' if val.endswith('c') else 'px'
    return positive_int(val.rstrip('c')), unit


def parse_layout_names(parts: Iterable[str]) -> list[str]:
    from kitty.layout.interface import all_layouts
    ans = []
    for p in parts:
        p = p.lower()
        if p in ('*', 'all'):
            ans.extend(sorted(all_layouts))
            continue
        name = p.partition(':')[0]
        if name not in all_layouts:
            raise ValueError(f'The window layout {p} is unknown')
        ans.append(p)
    return uniq(ans)


def to_layout_names(raw: str) -> list[str]:
    return parse_layout_names(x.strip() for x in raw.split(','))


def window_border_width(x: str | int | float) -> tuple[float, str]:
    unit = 'pt'
    if isinstance(x, str):
        trailer = x[-2:]
        if trailer in ('px', 'pt'):
            unit = trailer
            val = float(x[:-2])
        else:
            val = float(x)
    else:
        val = float(x)
    return max(0, val), unit


def edge_width(x: str, converter: Callable[[str], float] = positive_float) -> FloatEdges:
    parts = str(x).split()
    num = len(parts)
    if num == 1:
        val = converter(parts[0])
        return FloatEdges(val, val, val, val)
    if num == 2:
        v = converter(parts[0])
        h = converter(parts[1])
        return FloatEdges(h, v, h, v)
    if num == 3:
        top, h, bottom = map(converter, parts)
        return FloatEdges(h, top, h, bottom)
    top, right, bottom, left = map(converter, parts)
    return FloatEdges(left, top, right, bottom)


def optional_edge_width(x: str) -> FloatEdges:
    return edge_width(x, float)


def hide_window_decorations(x: str) -> int:
    if x == 'titlebar-only':
        return 0b10
    if x == 'titlebar-and-corners':
        return 0b100
    if to_bool(x):
        return 0b01
    return 0b00


def resize_draw_strategy(x: str) -> int:
    cmap = {'static': 0, 'scale': 1, 'blank': 2, 'size': 3}
    return cmap.get(x.lower(), 0)


def window_logo_scale(x: str) -> tuple[float, float]:
    parts = x.split(maxsplit=1)
    if len(parts) == 1:
        return positive_float(parts[0]), -1.0
    return positive_float(parts[0]), positive_float(parts[1])


def resize_debounce_time(x: str) -> tuple[float, float]:
    parts = x.split(maxsplit=1)
    if len(parts) == 1:
        return positive_float(parts[0]), 0.5
    return positive_float(parts[0]), positive_float(parts[1])


def visual_window_select_characters(x: str) -> str:
    import string
    valid_characters = string.digits + string.ascii_uppercase + "-=[]\\;',./`"
    ans = x.upper()
    ans_chars = set(ans)
    if not ans_chars.issubset(set(valid_characters)):
        raise ValueError(f'Invalid characters in visual_window_select_characters: {x} Only numbers (0-9) and alphabets (a-z,A-Z) are allowed. Ignoring.')
    if len(ans_chars) < len(x):
        raise ValueError(f'Invalid characters in visual_window_select_characters: {x} Contains identical numbers or alphabets, case insensitive. Ignoring.')
    return ans


def tab_separator(x: str) -> str:
    for q in '\'"':
        if x.startswith(q) and x.endswith(q):
            x = x[1:-1]
            if not x:
                return ''
            break
    if not x.strip():
        x = ('\xa0' * len(x)) if x else default_tab_separator
    return x


def tab_bar_edge(x: str) -> int:
    return {'top': defines.TOP_EDGE, 'bottom': defines.BOTTOM_EDGE}.get(x.lower(), defines.BOTTOM_EDGE)


def tab_font_style(x: str) -> tuple[bool, bool]:
    return {
        'bold-italic': (True, True),
        'bold': (True, False),
        'italic': (False, True)
    }.get(x.lower().replace('_', '-'), (False, False))


def tab_bar_min_tabs(x: str) -> int:
    return max(1, positive_int(x))


def tab_fade(x: str) -> tuple[float, ...]:
    return tuple(map(unit_float, x.split()))


def tab_activity_symbol(x: str) -> str:
    if x == 'none':
        return ''
    return tab_title_template(x)


def bell_on_tab(x: str) -> str:
    xl = x.lower()
    if xl in ('yes', 'y', 'true'):
        return '🔔 '
    if xl in ('no', 'n', 'false', 'none'):
        return ''
    return tab_title_template(x)


def tab_title_template(x: str) -> str:
    if x:
        for q in '\'"':
            if x.startswith(q) and x.endswith(q):
                x = x[1:-1]
                break
    return x


def active_tab_title_template(x: str) -> str | None:
    x = tab_title_template(x)
    return None if x == 'none' else x


def text_fg_override_threshold(x: str) -> tuple[float, Literal['%', 'ratio']]:
    val, unit = number_with_unit(x, '%', 'ratio')
    return val, cast(Literal['%', 'ratio'], unit)


ClearOn = Literal['next', 'focus']
default_clear_on: tuple[ClearOn, ...] = 'focus', 'next'
all_clear_on = get_args(ClearOn)


class NotifyOnCmdFinish(NamedTuple):
    when: str = 'never'
    duration: float = 5.0
    action: str = 'notify'
    cmdline: tuple[str, ...] = ()
    clear_on: tuple[ClearOn, ...] = default_clear_on


def notify_on_cmd_finish(x: str) -> NotifyOnCmdFinish:
    parts = x.split(maxsplit=3)
    if parts[0] not in ('never', 'unfocused', 'invisible', 'always'):
        raise ValueError(f'Unknown notify_on_cmd_finish value: {parts[0]}')
    when = parts[0]
    duration = 5.0
    if len(parts) > 1:
        duration = float(parts[1])
    action = 'notify'
    cmdline: tuple[str, ...] = ()
    clear_on = default_clear_on
    if len(parts) > 2:
        if parts[2] not in ('notify', 'bell', 'command'):
            raise ValueError(f'Unknown notify_on_cmd_finish action: {parts[2]}')
        action = parts[2]
        if action == 'notify':
            if len(parts) > 3:
                con: list[ClearOn] = []
                for x in parts[3].split():
                    if x not in all_clear_on:
                        raise ValueError(
                            f'notify_on_cmd_finish: notify clear_on value "{x}" is invalid. Valid values are: {", ".join(all_clear_on)}')
                    con.append(cast(ClearOn, x))
                clear_on = tuple(con)
        elif action == 'command':
            if len(parts) > 3:
                cmdline = tuple(to_cmdline(parts[3]))
            else:
                raise ValueError('notify_on_cmd_finish `command` action needs a command line')
    return NotifyOnCmdFinish(when, duration, action, cmdline, clear_on)


def config_or_absolute_path(x: str, env: dict[str, str] | None = None) -> str | None:
    if not x or x.lower() == 'none':
        return None
    return resolve_abs_or_config_path(x, env)


def filter_notification(val: str, current_val: dict[str, str]) -> Iterable[tuple[str, str]]:
    yield val, ''


def remote_control_password(val: str, current_val: dict[str, str]) -> Iterable[tuple[str, Sequence[str]]]:
    val = val.strip()
    if val:
        parts = to_cmdline(val, expand=False)
        if parts[0].startswith('-'):
            # this is done so in the future we can add --options to the cmd
            # line of remote_control_password
            raise ValueError('Passwords are not allowed to start with hyphens, ignoring this password')
        if len(parts) == 1:
            yield parts[0], ()
        else:
            yield parts[0], tuple(parts[1:])


def clipboard_control(x: str) -> tuple[str, ...]:
    return tuple(x.lower().split())


def allow_hyperlinks(x: str) -> int:
    if x == 'ask':
        return 0b11
    return 1 if to_bool(x) else 0


def titlebar_color(x: str) -> int:
    x = x.strip('"')
    if x == 'system':
        return 0
    if x == 'background':
        return 1
    try:
        return (color_as_int(to_color(x)) << 8) | 2
    except ValueError:
        log_error(f'Ignoring invalid title bar color: {x}')
    return 0


def macos_titlebar_color(x: str) -> int:
    x = x.strip('"')
    if x == 'light':
        return -1
    if x == 'dark':
        return -2
    return titlebar_color(x)


def macos_option_as_alt(x: str) -> int:
    x = x.lower()
    if x == 'both':
        return 0b11
    if x == 'left':
        return 0b10
    if x == 'right':
        return 0b01
    if to_bool(x):
        return 0b11
    return 0


class TabBarMarginHeight(NamedTuple):
    outer: float = 0
    inner: float = 0

    def __bool__(self) -> bool:
        return (self.outer + self.inner) > 0


def tab_bar_margin_height(x: str) -> TabBarMarginHeight:
    parts = x.split(maxsplit=1)
    if len(parts) != 2:
        log_error(f'Invalid tab_bar_margin_height: {x}, ignoring')
        return TabBarMarginHeight()
    ans = map(positive_float, parts)
    return TabBarMarginHeight(next(ans), next(ans))


def clone_source_strategies(x: str) -> frozenset[str]:
    return frozenset({'venv', 'conda', 'path', 'env_var'} & set(x.lower().split(',')))


def clear_all_mouse_actions(val: str, dict_with_parse_results: dict[str, Any] | None = None) -> bool:
    ans = to_bool(val)
    if ans and dict_with_parse_results is not None:
        dict_with_parse_results['mouse_map'] = [None]
    return ans


def clear_all_shortcuts(val: str, dict_with_parse_results: dict[str, Any] | None = None) -> bool:
    ans = to_bool(val)
    if ans and dict_with_parse_results is not None:
        dict_with_parse_results['map'] = [None]
    return ans


def font_features(val: str) -> Iterable[tuple[str, tuple[defines.ParsedFontFeature, ...]]]:
    if val == 'none':
        return
    parts = val.split()
    if len(parts) < 2:
        log_error(f"Ignoring invalid font_features {val}")
        return
    if parts[0]:
        features = []
        for feat in parts[1:]:
            try:
                features.append(defines.ParsedFontFeature(feat))
            except ValueError:
                log_error(f'Ignoring invalid font feature: {feat}')
        yield parts[0], tuple(features)


def modify_font(val: str) -> Iterable[tuple[str, FontModification]]:
    parts = val.split()
    pos, plen = 0, len(parts)
    if plen < 2:
        log_error(f"Ignoring invalid modify_font: {val}")
        return
    mtype: ModificationType | None = getattr(ModificationType, parts[pos], None)
    if mtype is None:
        log_error(f"Ignoring invalid modify_font with unknown modification type: {parts[pos]}")
        return
    pos += 1
    font_name = ''
    if mtype is ModificationType.size:
        font_name = parts[pos]
        pos += 1
    if plen - pos < 1:
        log_error(f"Ignoring invalid modify_font: {val}")
        return
    sz = parts[pos]
    pos += 1
    munit = ModificationUnit.pt
    if sz.endswith('%'):
        munit = ModificationUnit.percent
        sz = sz[:-1]
    elif sz.endswith('px'):
        munit = ModificationUnit.pixel
        sz = sz[:-2]
    try:
        mvalue = float(sz)
    except Exception:
        log_error(f'Ignoring modify_font with invalid size: {sz}')
        return
    key = mtype.name
    if font_name:
        key += f':{font_name}'
    yield key, FontModification(mtype, ModificationValue(mvalue, munit), font_name)


def env(val: str, current_val: dict[str, str]) -> Iterable[tuple[str, str]]:
    val = val.strip()
    if val:
        if '=' in val:
            key, v = val.split('=', 1)
            key, v = key.strip(), v.strip()
            if key:
                if v:
                    v = expandvars(v, current_val)
                yield key, v
        else:
            yield val, DELETE_ENV_VAR


def store_multiple(val: str, current_val: Container[str]) -> Iterable[tuple[str, str]]:
    val = val.strip()
    if val not in current_val:
        yield val, val


def menu_map(val: str, current_val: Container[str]) -> Iterable[tuple[tuple[str, ...], str]]:
    parts = val.split(maxsplit=1)
    if len(parts) != 2:
        raise ValueError(f'Ignoring invalid menu action: {val}')
    if parts[0] != 'global':
        raise ValueError(f'Unknown menu type: {parts[0]}. Known types: global')
    start = 0
    if parts[1].startswith('"'):
        start = 1
        idx = parts[1].find('"', 1)
        if idx == -1:
            raise ValueError(f'The menu entry name in {val} must end with a double quote')
    else:
        idx = parts[1].find(' ')
        if idx == -1:
            raise ValueError(f'The menu entry {val} must have an action')
    location = ('global',) + tuple(parts[1][start:idx].split('::'))
    yield location, parts[1][idx+1:].lstrip()


allowed_shell_integration_values = frozenset({'enabled', 'disabled', 'no-rc', 'no-cursor', 'no-title', 'no-prompt-mark', 'no-complete', 'no-cwd', 'no-sudo'})


def shell_integration(x: str) -> frozenset[str]:
    q = frozenset(x.lower().split())
    if not q.issubset(allowed_shell_integration_values):
        log_error(f'Invalid shell integration options: {q - allowed_shell_integration_values}, ignoring')
        return q & allowed_shell_integration_values or frozenset({'invalid'})
    return q


def confirm_close(x: str) -> tuple[int, bool]:
    parts = x.split(maxsplit=1)
    num = int(parts[0])
    allow_background = len(parts) > 1 and parts[1] == 'count-background'
    return num, allow_background


def underline_exclusion(x: str) -> tuple[float, Literal['', 'px', 'pt']]:
    try:
        return float(x), ''
    except Exception:
        unit: Literal['pt', 'px'] = x[-2:]  # type: ignore
        if unit not in ('px', 'pt'):
            raise ValueError(f'Invalid underline_exclusion with unrecognized unit: {x}')
        try:
            val = float(x[:-2])
        except Exception:
            raise ValueError(f'Invalid underline_exclusion with non numberic value: {x}')
        return val, unit


def paste_actions(x: str) -> frozenset[str]:
    s = frozenset({'quote-urls-at-prompt', 'confirm', 'filter', 'confirm-if-large', 'replace-dangerous-control-codes', 'replace-newline', 'no-op'})
    q = frozenset(x.lower().split(','))
    if not q.issubset(s):
        raise ValueError(f'Invalid paste actions: {q - s}, ignoring')
    return q


def action_alias(val: str) -> Iterable[tuple[str, str]]:
    parts = val.split(maxsplit=1)
    if len(parts) > 1:
        alias_name, rest = parts
        yield alias_name, rest


kitten_alias = action_alias


def symbol_map_parser(val: str, min_size: int = 2) -> Iterable[tuple[tuple[int, int], str]]:
    parts = val.split()

    if len(parts) < min_size:
        raise ValueError('must have codepoints AND font name')
    family = ' '.join(parts[1:])

    def to_chr(x: str) -> int:
        if not x.startswith('U+'):
            raise ValueError(f'{x} is not a unicode codepoint of the form U+number')
        return int(x[2:], 16)

    for x in parts[0].split(','):
        a_, b_ = x.replace('–', '-').partition('-')[::2]
        b_ = b_ or a_
        a, b = map(to_chr, (a_, b_))
        if b < a or max(a, b) > sys.maxunicode or min(a, b) < 1:
            raise ValueError(f'Invalid range: {a:x} - {b:x}')
        yield (a, b), family


def symbol_map(val: str) -> Iterable[tuple[tuple[int, int], str]]:
    yield from symbol_map_parser(val)


def narrow_symbols(val: str) -> Iterable[tuple[tuple[int, int], int]]:
    for x, y in symbol_map_parser(val, min_size=1):
        yield x, int(y or 1)


def parse_key_action(action: str, action_type: MapType = MapType.MAP) -> KeyAction:
    parts = action.strip().split(maxsplit=1)
    func = parts[0]
    if len(parts) == 1:
        return KeyAction(func, ())
    rest = parts[1]
    parser = func_with_args.get(func)
    if parser is None:
        raise KeyError(f'Unknown action: {func}')
    func, args = parser(func, rest)
    return KeyAction(func, tuple(args))


class ActionAlias(NamedTuple):
    name: str
    value: str
    replace_second_arg: bool = False


class AliasMap:

    def __init__(self) -> None:
        self.aliases: dict[str, list[ActionAlias]] = {}

    def append(self, name: str, aa: ActionAlias) -> None:
        self.aliases.setdefault(name, []).append(aa)

    def update(self, aa: 'AliasMap') -> None:
        self.aliases.update(aa.aliases)

    @lru_cache(maxsize=256)
    def resolve_aliases(self, definition: str, map_type: MapType = MapType.MAP) -> tuple[KeyAction, ...]:
        return tuple(resolve_aliases_and_parse_actions(definition, self.aliases, map_type))


def build_action_aliases(raw: dict[str, str], first_arg_replacement: str = '') -> AliasMap:
    ans = AliasMap()
    if first_arg_replacement:
        for alias_name, rest in raw.items():
            ans.append(first_arg_replacement, ActionAlias(alias_name, rest, True))
    else:
        for alias_name, rest in raw.items():
            ans.append(alias_name, ActionAlias(alias_name, rest))
    return ans


def resolve_aliases_and_parse_actions(
    defn: str, aliases: dict[str, list[ActionAlias]], map_type: MapType
) -> Iterator[KeyAction]:
    parts = defn.split(maxsplit=1)
    if len(parts) == 1:
        possible_alias = defn
        rest = ''
    else:
        possible_alias = parts[0]
        rest = parts[1]
    for alias in aliases.get(possible_alias, ()):
        if alias.replace_second_arg:  # kitten_alias
            if not rest:
                continue
            parts = rest.split(maxsplit=1)
            if parts[0] != alias.name:
                continue
            new_defn = f'{possible_alias} {alias.value}{f" {parts[1]}" if len(parts) > 1 else ""}'
            new_aliases = aliases.copy()
            new_aliases[possible_alias] = [a for a in aliases[possible_alias] if a is not alias]
            yield from resolve_aliases_and_parse_actions(new_defn, new_aliases, map_type)
            return
        else:  # action_alias
            new_defn = f'{alias.value} {rest}' if rest else alias.value
            new_aliases = aliases.copy()
            new_aliases.pop(possible_alias)
            yield from resolve_aliases_and_parse_actions(new_defn, new_aliases, map_type)
            return

    if possible_alias == 'combine':
        sep, rest = rest.split(maxsplit=1)
        parts = re.split(fr'\s*{re.escape(sep)}\s*', rest)
        for x in parts:
            if x:
                yield from resolve_aliases_and_parse_actions(x, aliases, map_type)
    else:
        yield parse_key_action(defn, map_type)


class BaseDefinition:
    no_op_actions = frozenset(('noop', 'no-op', 'no_op'))
    map_type: MapType = MapType.MAP
    definition_location: CurrentlyParsing

    def __init__(self, definition: str = '') -> None:
        if definition in BaseDefinition.no_op_actions:
            definition = ''
        self.definition = definition
        self.definition_location = currently_parsing.__copy__()

    def pretty_repr(self, *fields: str) -> str:
        kwds = []
        defaults = self.__class__()
        for f in fields:
            val = getattr(self, f)
            if val != getattr(defaults, f):
                kwds.append(f'{f}={val!r}')
        if self.definition:
            kwds.append(f'definition={self.definition!r}')
        return f'{self.__class__.__name__}({", ".join(kwds)})'


def resolve_key_mods(kitty_mod: int, mods: int) -> int:
    return SingleKey(mods=mods).resolve_kitty_mod(kitty_mod).mods


class MouseMapping(BaseDefinition):
    map_type: MapType = MapType.MOUSE_MAP

    def __init__(
        self, button: int = 0, mods: int = 0, repeat_count: int = 1, grabbed: bool = False,
        definition: str = ''
    ):
        super().__init__(definition)
        self.button = button
        self.mods = mods
        self.repeat_count = repeat_count
        self.grabbed = grabbed

    def __repr__(self) -> str:
        return self.pretty_repr('button', 'mods', 'repeat_count', 'grabbed')

    def resolve_and_copy(self, kitty_mod: int) -> 'MouseMapping':
        ans = MouseMapping(
            self.button, resolve_key_mods(kitty_mod, self.mods), self.repeat_count, self.grabbed,
            self.definition)
        ans.definition_location = self.definition_location
        return ans

    @property
    def trigger(self) -> MouseEvent:
        return MouseEvent(self.button, self.mods, self.repeat_count, self.grabbed)


T = TypeVar('T')


class LiteralField(Generic[T]):
    def __init__(self, vals: tuple[T, ...]):
        self._vals = vals

    def __set_name__(self, owner: object, name: str) -> None:
        self._name = "_" + name

    def __get__(self, obj: object, type: type | None = None) -> T:
        if obj is None:
            return self._vals[0]
        return getattr(obj, self._name, self._vals[0])

    def __set__(self, obj: object, value: str) -> None:
        if value not in self._vals:
            raise KeyError(f'Invalid value for {self._name[1:]}: {value!r}')
        object.__setattr__(obj, self._name, value)


OnUnknown = Literal['beep', 'end', 'ignore', 'passthrough']
OnAction = Literal['keep', 'end']


@dataclass(init=False, frozen=True)
class KeyMapOptions:
    when_focus_on: str = ''
    new_mode: str = ''
    mode: str = ''
    on_unknown: LiteralField[OnUnknown] = LiteralField[OnUnknown](get_args(OnUnknown))
    on_action: LiteralField[OnAction] = LiteralField[OnAction](get_args(OnAction))


default_key_map_options = KeyMapOptions()
allowed_key_map_options = frozenset(f.name for f in fields(KeyMapOptions))


class KeyDefinition(BaseDefinition):

    def __init__(
        self, is_sequence: bool = False, trigger: SingleKey = SingleKey(),
        rest: tuple[SingleKey, ...] = (), definition: str = '',
        options: KeyMapOptions = default_key_map_options
    ):
        super().__init__(definition)
        self.is_sequence = is_sequence
        self.trigger = trigger
        self.rest = rest
        self.options = options

    @property
    def is_suitable_for_global_shortcut(self) -> bool:
        return not self.options.when_focus_on and not self.options.mode and not self.options.new_mode and not self.is_sequence

    @property
    def full_key_sequence_to_trigger(self) -> tuple[SingleKey, ...]:
        return (self.trigger,) + self.rest

    @property
    def unique_identity_within_keymap(self) -> tuple[tuple[SingleKey, ...], str]:
        return self.full_key_sequence_to_trigger, self.options.when_focus_on

    def __repr__(self) -> str:
        return self.pretty_repr('is_sequence', 'trigger', 'rest', 'options')

    def human_repr(self) -> str:
        ans = self.definition or 'no-op'
        if self.options.when_focus_on:
            ans = f'[--when-focus-on={self.options.when_focus_on}]{ans}'
        return ans

    def shift_sequence_and_copy(self) -> 'KeyDefinition':
        return KeyDefinition(self.is_sequence, self.trigger, self.rest[1:], self.definition, self.options)

    def resolve_and_copy(self, kitty_mod: int) -> 'KeyDefinition':
        def r(k: SingleKey) -> SingleKey:
            return k.resolve_kitty_mod(kitty_mod)
        ans = KeyDefinition(
            self.is_sequence, r(self.trigger), tuple(map(r, self.rest)),
            self.definition, self.options
        )
        ans.definition_location = self.definition_location
        return ans


class KeyboardMode:

    on_unknown: OnUnknown = get_args(OnUnknown)[0]
    on_action : OnAction = get_args(OnAction)[0]
    sequence_keys: list[defines.KeyEvent] | None = None

    def __init__(self, name: str = '') -> None:
        self.name = name
        self.keymap: KeyMap = defaultdict(list)


KeyboardModeMap = dict[str, KeyboardMode]


def parse_options_for_map(val: str) -> tuple[KeyMapOptions, str]:
    expecting_arg = ''
    ans = KeyMapOptions()
    s = Shlex(val)
    while (tok := s.next_word())[0] > -1:
        x = tok[1]
        if expecting_arg:
            object.__setattr__(ans, expecting_arg, x)
            expecting_arg = ''
        elif x.startswith('--'):
            expecting_arg = x[2:]
            k, sep, v = expecting_arg.partition('=')
            k = k.replace('-', '_')
            expecting_arg = k
            if expecting_arg not in allowed_key_map_options:
                raise KeyError(f'The map option {x} is unknown. Allowed options: {", ".join(allowed_key_map_options)}')
            if sep == '=':
                object.__setattr__(ans, k, v)
                expecting_arg = ''
        else:
            return ans, val[tok[0]:]
    return ans, ''


def parse_map(val: str) -> Iterable[KeyDefinition]:
    parts = val.split(maxsplit=1)
    options = default_key_map_options
    if len(parts) == 2:
        sc, action = parts
        if sc.startswith('--'):
            options, leftover = parse_options_for_map(val)
            parts = leftover.split(maxsplit=1)
            if len(parts) == 1:
                sc, action = parts[0], ''
            else:
                sc = parts[0]
                action = ' '.join(parts[1:])
    else:
        sc, action = val, ''
    sc, action = sc.strip().strip(sequence_sep), action.strip()
    if not sc:
        return
    is_sequence = sequence_sep in sc
    if is_sequence:
        trigger: SingleKey | None = None
        restl: list[SingleKey] = []
        for part in sc.split(sequence_sep):
            try:
                mods, is_native, key = parse_shortcut(part)
            except InvalidMods:
                return
            if key == 0:
                if mods is not None:
                    log_error(f'Shortcut: {sc} has unknown key, ignoring')
                return
            if trigger is None:
                trigger = SingleKey(mods, is_native, key)
            else:
                restl.append(SingleKey(mods, is_native, key))
        rest = tuple(restl)
    else:
        try:
            mods, is_native, key = parse_shortcut(sc)
        except InvalidMods:
            return
        if key == 0:
            if mods is not None:
                log_error(f'Shortcut: {sc} has unknown key, ignoring')
            return
    if is_sequence:
        if trigger is not None:
            yield KeyDefinition(True, trigger, rest, definition=action, options=options)
    else:
        assert key is not None
        yield KeyDefinition(False, SingleKey(mods, is_native, key), definition=action, options=options)


def parse_mouse_map(val: str) -> Iterable[MouseMapping]:
    parts = val.split(maxsplit=3)
    if len(parts) == 4:
        xbutton, event, modes, action = parts
    elif len(parts) > 2:
        xbutton, event, modes = parts
        action = ''
    else:
        log_error(f'Ignoring invalid mouse action: {val}')
        return
    kparts = xbutton.split('+')
    if len(kparts) > 1:
        mparts, obutton = kparts[:-1], kparts[-1].lower()
        mods = parse_mods(mparts, obutton)
        if mods is None:
            return
    else:
        obutton = parts[0].lower()
        mods = 0
    try:
        b = mouse_button_map.get(obutton, obutton)[1:]
        button = getattr(defines, f'GLFW_MOUSE_BUTTON_{b}')
    except Exception:
        log_error(f'Mouse button: {xbutton} not recognized, ignoring')
        return
    try:
        count = mouse_trigger_count_map[event.lower()]
    except KeyError:
        log_error(f'Mouse event type: {event} not recognized, ignoring')
        return
    specified_modes = frozenset(modes.lower().split(','))
    if specified_modes - {'grabbed', 'ungrabbed'}:
        log_error(f'Mouse modes: {modes} not recognized, ignoring')
        return
    for mode in sorted(specified_modes):
        yield MouseMapping(button, mods, count, mode == 'grabbed', definition=action)


def parse_font_spec(spec: str) -> FontSpec:
    return FontSpec.from_setting(spec)


JumpTypes = Literal['start', 'end', 'none', 'both']


class EasingFunction(NamedTuple):
    type: Literal['steps', 'linear', 'cubic-bezier', ''] = ''

    num_steps: int = 0
    jump_type: JumpTypes = 'end'

    linear_x: tuple[float, ...] = ()
    linear_y: tuple[float, ...] = ()

    cubic_bezier_points: tuple[float, ...] = ()

    def __repr__(self) -> str:
        fields = ', '.join(f'{f}={getattr(self, f)!r}' for f in self._fields if getattr(self, f) != self._field_defaults[f])
        return f'kitty.options.utils.EasingFunction({fields})'

    def __bool__(self) -> bool:
        return bool(self.type)

    @classmethod
    def cubic_bezier(cls, params: str) -> 'EasingFunction':
        parts = params.replace(',', ' ').split()
        if len(parts) != 4:
            raise ValueError('cubic-bezier easing function must have four points')
        return cls(type='cubic-bezier', cubic_bezier_points=(
            unit_float(parts[0]), float(parts[1]), unit_float(parts[2]), float(parts[3])))

    @classmethod
    def linear(cls, params: str) -> 'EasingFunction':
        parts = params.split(',')
        if len(parts) < 2:
            raise ValueError('Must specify at least two points for the linear easing function')
        xaxis: list[float] = []
        yaxis: list[float] = []

        def balance(end: float) -> None:
            extra = len(yaxis) - len(xaxis)
            if extra <= 0:
                return
            start = xaxis[-1] if xaxis else 0.
            delta = (end - start) / max(1, extra - 1)
            if delta <= 0.:
                raise ValueError(f'Linear easing curve must have strictly increasing points: {params} does not')
            if xaxis:
                for i in range(extra):
                    xaxis.append(start + (i+1) * delta)
            else:
                for i in range(extra):
                    xaxis.append(i * delta)

        def add_point(y: float, x: float | None = None) -> None:
            if x is None:
                yaxis.append(y)
            else:
                x = unit_float(x)
                balance(x)
                xaxis.append(x)
                yaxis.append(y)

        for r in parts:
            points = r.strip().split()
            y = unit_float(points[0])
            if len(points) == 1:
                add_point(y)
            elif len(points) == 2:
                add_point(y, percent(points[1]))
            elif len(points) == 3:
                add_point(y, percent(points[1]))
                add_point(y, percent(points[2]))
            else:
                raise ValueError(f'{r} has too many points for a linear easing curve parameter')
        balance(1)
        return cls(type='linear', linear_x=tuple(xaxis), linear_y=tuple(yaxis))

    @classmethod
    def steps(cls, params: str) -> 'EasingFunction':
        parts = params.replace(',', ' ').split()
        jump_type: JumpTypes = 'end'
        if len(parts) == 2:
            n = int(parts[0])
            jt = parts[1]
            mapping: dict[str, JumpTypes] = {
                'jump-start': 'start', 'start': 'start', 'end': 'end', 'jump-end': 'end', 'jump-none': 'none', 'jump-both': 'both'
            }
            try:
                jump_type = mapping[jt.lower()]
            except KeyError:
                raise KeyError(f'{jt} is not a valid jump type for a linear easing function')
            if jump_type == 'none':
                n = max(2, n)
            else:
                n = max(1, n)
        else:
            n = max(1, int(parts[0]))
        return cls(type='steps', jump_type=jump_type, num_steps=n)


def parse_animation(spec: str, interval: float = -1.) -> tuple[float, EasingFunction, EasingFunction]:
    with suppress(Exception):
        interval = float(spec)
        return interval, EasingFunction(), EasingFunction()

    m = [EasingFunction(), EasingFunction()]
    def parse_func(func_name: str, params: str) -> None:
        idx = 1 if m[0] else 0
        if m[idx]:
            raise ValueError(f'{spec} specified more than two easing functions')
        if func_name == 'cubic-bezier':
            m[idx] = EasingFunction.cubic_bezier(params)
        elif func_name == 'linear':
            m[idx] = EasingFunction.linear(params)
        elif func_name == 'steps':
            m[idx] = EasingFunction.steps(params)
        else:
            raise KeyError(f'{func_name} is not a valid easing function')

    for match in re.finditer(r'([-+.0-9a-zA-Z]+)(?:\(([^)]*)\)){0,1}', spec):
        func_name, params = match.group(1, 2)
        if params:
            parse_func(func_name, params)
            continue
        with suppress(Exception):
            interval = float(func_name)
            continue
        if func_name == 'ease-in-out':
            parse_func('cubic-bezier', '0.42, 0, 0.58, 1')
        elif func_name == 'linear':
            parse_func('cubic-bezier', '0, 0, 1, 1')
        elif func_name == 'ease':
            parse_func('cubic-bezier', '0.25, 0.1, 0.25, 1')
        elif func_name == 'ease-out':
            parse_func('cubic-bezier', '0, 0, 0.58, 1')
        elif func_name == 'ease-in':
            parse_func('cubic-bezier', '0.42, 0, 1, 1')
        elif func_name == 'step-start':
            parse_func('steps', '1, start')
        elif func_name == 'step-end':
            parse_func('steps', '1, end')
        else:
            raise KeyError(f'{func_name} is not a valid easing function')
    return interval, m[0], m[1]


def cursor_blink_interval(spec: str) -> tuple[float, EasingFunction, EasingFunction]:
    return parse_animation(spec)


class MouseHideWait(NamedTuple):
    hide_wait: float
    show_wait: float
    show_threshold: int
    scroll_show: bool


def mouse_hide_wait(x: str) -> MouseHideWait:
    parts = x.split(maxsplit=3)
    if len(parts) != 1 and len(parts) != 4:
        log_error(f'Invalid mouse_hide_wait: {x}, ignoring')
        return MouseHideWait(3.0, 0.0, 40, True)
    if len(parts) == 1:
        return MouseHideWait(float(parts[0]), 0.0, 40, True)
    else:
        return MouseHideWait(float(parts[0]), float(parts[1]), int(parts[2]), to_bool(parts[3]))


def visual_bell_duration(spec: str) -> tuple[float, EasingFunction, EasingFunction]:
    return parse_animation(spec, interval=0.)


pointer_shape_names = (
# start pointer shape names (auto generated by gen-key-constants.py do not edit)
    'arrow',
    'beam',
    'text',
    'pointer',
    'hand',
    'help',
    'wait',
    'progress',
    'crosshair',
    'cell',
    'vertical-text',
    'move',
    'e-resize',
    'ne-resize',
    'nw-resize',
    'n-resize',
    'se-resize',
    'sw-resize',
    's-resize',
    'w-resize',
    'ew-resize',
    'ns-resize',
    'nesw-resize',
    'nwse-resize',
    'zoom-in',
    'zoom-out',
    'alias',
    'copy',
    'not-allowed',
    'no-drop',
    'grab',
    'grabbing',
# end pointer shape names
)


def pointer_shape_when_dragging(spec: str) -> tuple[str, str]:
    parts = spec.split(maxsplit=1)
    first = parts[0]
    if first not in pointer_shape_names:
        raise ValueError(f'{first} is not a valid pointer shape name')
    second = parts[1] if len(parts) > 1 else first
    if second not in pointer_shape_names:
        raise ValueError(f'{second} is not a valid pointer shape name')
    return first, second


def transparent_background_colors(spec: str) -> tuple[tuple[Color, float], ...]:
    if not spec:
        return ()
    ans: list[tuple[Color, float]] = []
    seen: dict[Color, int] = {}
    for part in spec.split():
        col, sep, alpha = part.partition('@')
        c = to_color(col)
        o = max(-1, min(float(alpha) if alpha else -1, 1))
        if (idx := seen.get(c)) is not None:
            ans[idx] = c, o
            continue
        seen[c] = len(ans)
        ans.append((c, o))
    return tuple(ans[:7])


def deprecated_hide_window_decorations_aliases(key: str, val: str, ans: dict[str, Any]) -> None:
    if not hasattr(deprecated_hide_window_decorations_aliases, key):
        setattr(deprecated_hide_window_decorations_aliases, key, True)
        log_error(f'The option {key} is deprecated. Use hide_window_decorations instead.')
    if to_bool(val):
        if is_macos and key == 'macos_hide_titlebar' or (not is_macos and key == 'x11_hide_window_decorations'):
            ans['hide_window_decorations'] = True


def deprecated_macos_show_window_title_in_menubar_alias(key: str, val: str, ans: dict[str, Any]) -> None:
    if not hasattr(deprecated_macos_show_window_title_in_menubar_alias, key):
        setattr(deprecated_macos_show_window_title_in_menubar_alias, 'key', True)
        log_error(f'The option {key} is deprecated. Use macos_show_window_title_in menubar instead.')
    macos_show_window_title_in = ans.get('macos_show_window_title_in', 'all')
    if to_bool(val):
        if macos_show_window_title_in == 'none':
            macos_show_window_title_in = 'menubar'
        elif macos_show_window_title_in == 'window':
            macos_show_window_title_in = 'all'
    else:
        if macos_show_window_title_in == 'all':
            macos_show_window_title_in = 'window'
        elif macos_show_window_title_in == 'menubar':
            macos_show_window_title_in = 'none'
    ans['macos_show_window_title_in'] = macos_show_window_title_in


def deprecated_send_text(key: str, val: str, ans: dict[str, Any]) -> None:
    parts = val.split(' ')

    def abort(msg: str) -> None:
        log_error(f'Send text: {val} is invalid ({msg}), ignoring')

    if len(parts) < 3:
        return abort('Incomplete')
    mode, sc = parts[:2]
    text = ' '.join(parts[2:])
    key_str = f'{sc} send_text {mode} {text}'
    for k in parse_map(key_str):
        ans['map'].append(k)


def deprecated_adjust_line_height(key: str, x: str, opts_dict: dict[str, Any]) -> None:
    fm = {'adjust_line_height': 'cell_height', 'adjust_baseline': 'baseline', 'adjust_column_width': 'cell_width'}[key]
    mtype = getattr(ModificationType, fm)
    if x.endswith('%'):
        ans = float(x[:-1].strip())
        if ans < 0:
            log_error(f'Percentage adjustments of {key} must be positive numbers')
            return
        opts_dict['modify_font'][fm] = FontModification(mtype, ModificationValue(ans, ModificationUnit.percent))
    else:
        opts_dict['modify_font'][fm] = FontModification(mtype, ModificationValue(int(x), ModificationUnit.pixel))
