"""sshj.config — user settings: default sort mode + keymap bindings.

Lives in the XDG config home (``~/.config/sshj/config.json``), NOT the state
dir (state is runtime data; config is user intent). Optional — every field
has a default, so a missing/invalid file degrades to the defaults.

Shape::

    {
      "sort": "last",                 # "last" | "last_success" | "alpha"
      "keymap": { "down": ["down", "j"], "up": ["up", "k"] }
    }

The config's ``keymap`` keys are **symbols** (``"up"``, ``"right"``,
``"tab"``, ``"ctrl+r"``, …) for readability; single letters are their own
symbol. Symbols resolve to the REAL terminal keys the dispatch compares
against (``ui.KEYMAP``'s constants) via ``resolve_key`` / ``resolved_keymap``
— so the config layer and the dispatch layer can never drift.

``keymap`` merges with the default (the default keys are kept; your entries
are ADDED, so binding ``"j"`` to ``down`` keeps the down arrow too — that is
the "alternate key" mechanism). **No single letter is bound by default**:
every printable character is free for the search filter. Opting a single
letter into a binding is explicit and yours alone ("if someone does that they
know what they did") — and the input-mode rule in run.py makes a bound
single-char key *type* while the input field has content.

``SSHJ_CONFIG_FILE`` overrides the path (tests / exotic installs).
"""
import json
import os


def _config_path():
    # resolved per call (not at import) so SSHJ_CONFIG_FILE / XDG_CONFIG_HOME
    # set after import (tests) still take effect.
    return os.environ.get("SSHJ_CONFIG_FILE") or os.path.join(
        os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
        "sshj", "config.json")


CONFIG_FILE = _config_path()   # default for introspection; load uses _config_path()

SORT_MODES = ("last", "last_success", "alpha")

# The default keymap, as SYMBOLS, sourced from the ONE table: ui.KEYMAP.
# action -> [symbol-keys]. NO single letters — navigation is the arrows /
# pgup / pgdn / home / end; vim-style single letters are opt-in via the
# config's "keymap" field. "left" is the empty-field cancel/close.
DEFAULT_KEYMAP = {
    "up":            ["up"],
    "down":          ["down"],
    "page_up":       ["pgup"],
    "page_dn":       ["pgdn"],
    "home":          ["home"],
    "end":           ["end"],
    "connect":       ["enter"],
    "stage_alias":   ["right"],
    "stage_address": ["tab"],
    "left":          ["left"],
    "quit":          ["esc"],
    "sort":          ["ctrl+r"],
}
# the fixed backspace key (not part of the configurable set; it is the
# field editor, never a "binding" that gets gated by the input-mode rule).
BACK_KEY = "bs"


# A config symbol is a known default-key symbol (its spelling in
# DEFAULT_KEYMAP) or a single printable character (a literal letter/space).
def _known_symbols():
    syms = set()
    for a, ks in DEFAULT_KEYMAP.items():
        syms.update(ks)
    syms.add(BACK_KEY)
    return syms


def is_single_char(key):
    """A config symbol / key is a "single-char binding" iff it is exactly
    one printable character (j, k, a, space, /, …). Such bindings are what
    the input-mode rule suppresses while the field has content."""
    return len(key) == 1 and key.isprintable()


_SYMBOL_REAL = None


def _symbol_real():
    """symbol -> REAL key, built ONCE from ui.KEYMAP (the single source of
    truth) so a default-key spelling always resolves to the exact constant
    the dispatch compares against — robust no matter what bytes K_UP /
    K_TAB / … actually are. ``"bs"`` maps to the term's backspace key."""
    global _SYMBOL_REAL
    if _SYMBOL_REAL is None:
        from . import ui
        m = {}
        for act, syms in DEFAULT_KEYMAP.items():
            sym = syms[0]
            for k, _alias, a, _lbl in ui.KEYMAP:
                if a == act and k is not None:
                    m[sym] = k
                    break
        m[BACK_KEY] = ui.K_BS
        _SYMBOL_REAL = m
    return _SYMBOL_REAL


def resolve_key(sym):
    """A config symbol (or a single-char literal) -> the REAL key the
    dispatch compares against. Single chars resolve to themselves (they are
    their own key). Anything that is neither a known default-key spelling
    nor a single printable char resolves to None — a typo in the config is
    then DROPPED (a dead binding is worse than a missing one), not honored.
    A REAL key that happens to be passed in is returned as-is (idempotent
    for keys already in the symbol map's values)."""
    m = _symbol_real()
    if sym in m:
        return m[sym]
    if is_single_char(sym):
        return sym
    if any(sym == v for v in m.values()):
        return sym          # already a real key
    return None


def default_keymap():
    """The default keymap as SYMBOLS (action -> [symbols]) — a copy, so a
    caller can mutate it (tests) without touching the module global."""
    return {a: list(ks) for a, ks in DEFAULT_KEYMAP.items()}


def resolved_keymap(kmap):
    """action -> [REAL keys], over a (symbol) keymap, every key resolved via
    ``resolve_key``. Unresolvable (typo) symbols are dropped; single chars
    pass through as the literal the dispatch will match."""
    out = {}
    for a, ks in kmap.items():
        real = []
        for k in ks:
            r = resolve_key(k)
            if r is not None:
                real.append(r)
        out[a] = real
    return out


def key_action_map(kmap):
    """key -> action over a (REAL) keymap. On a conflicting binding the
    action that appears LATER wins (deterministic; a single letter the user
    bound to two actions loses to the later action — document in help)."""
    m = {}
    for a, ks in kmap.items():
        for k in ks:
            m[k] = a
    return m


def _load():
    try:
        with open(_config_path()) as f:
            return json.load(f)
    except Exception:
        return {}


def load_config():
    """Return {'sort': <mode>, 'keymap': {action: [symbol-keys]}} — the
    defaults merged with any user config (symbols; resolve with
    resolved_keymap before dispatch)."""
    raw = _load()
    sort = raw.get("sort", "last")
    if sort not in SORT_MODES:
        sort = "last"
    keymap = default_keymap()
    for a, ks in (raw.get("keymap") or {}).items():
        if a not in DEFAULT_KEYMAP:
            continue                       # unknown action: ignore
        if isinstance(ks, str):
            ks = [ks]
        if not isinstance(ks, list):
            continue
        seen = set(keymap[a])
        keymap[a] += [k for k in ks
                      if k not in seen and (k in _known_symbols()
                                            or is_single_char(k))]
    return {"sort": sort, "keymap": keymap}
