#!/usr/bin/python3
"""
Rover is a text-based light-weight frontend for update-alternatives.
Copyright (C) 2018 Mo Zhou <lumin@debian.org>
License: GPL-3.0+
"""
from typing import List, Dict, Tuple, NamedTuple
import argparse
import re
import sys
import subprocess
import urwid

__VERSION__ = "1.0"
__AUTHOR__ = "Mo Zhou <lumin@debian.org>"

__DESIGN__ = """
┌─────────────────────┬───────────────────────────────────────────────────────┐
│List of alternative  │List of available candidates for the symlink.          │
│names.               │                                                       │
│                     │                                                       │
│width: WIDTH*24/80   │width: full-width(lpane)-1                             │
│height: full-1       │height: (full*1/2)-1                                   │
│name: lpane          │name: rpane                                            │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     ├───────────────────────────────────────────────────────┤
│                     │Details about the highlighted candidate, incl. slaves. │
│                     │name: ipane                                            │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
│                     │                                                       │
├─────────────────────┴───────────────────────────────────────────────────────┤
│Status Bar. width: full, height: 1, name: status                             │
└─────────────────────────────────────────────────────────────────────────────┘
"""


def Version():
    """
    Print version information to screen.
    """
    print(f"Rover {__VERSION__}")
    exit(0)


def deb822parse(deb822: List[str]) -> List[Dict]:
    """
    Parse DEB822-formatted text into a list of dictionaries.
    >>> lines = list(x.rstrip() for x in open('txt', 'r').readlines())
    >>> print(json.dumps(deb822parse(lines), indent=2))
    """
    paragraphs = []

    def _deb822parse(lines: List[str], paras: List[Dict], key: str = None):
        if not lines:
            return paras
        elif re.match(r"^\w*:\s*.*$", lines[0]):
            if not paras:
                paras.append({})
            key, val = re.match(r"^(\w*):\s*(.*)\s*$", lines[0]).groups()
            paras[-1].update({key: ([val] if val else [])})
            return _deb822parse(lines[1:], paragraphs, key)
        elif re.match(r"^\s+.*$", lines[0]):
            if not paras or not key:
                raise SyntaxError("Malformed Input")
            val = re.match(r"^\s*(.*)\s*$", lines[0]).groups()[0]
            paras[-1][key].append(val)
            return _deb822parse(lines[1:], paragraphs, key)
        elif re.match(r"^\s*$", lines[0]):
            paras.append({})
            return _deb822parse(lines[1:], paragraphs, None)
        else:
            raise Exception("Internal Parser Error")

    _deb822parse(deb822, paragraphs)
    for d in paragraphs:
        for k, v in d.items():
            if isinstance(v, list) and len(v) == 1:
                d[k] = v[0]
    return paragraphs


class UpdateAlternative:
    """
    Wrapper around the update-alternatives(1) command.

    Provides structured access to querying, filtering, and setting
    Debian alternatives selections.
    """

    _CMD = "update-alternatives"

    class Selection(NamedTuple):
        """A single line from --get-selections: name, mode, path."""

        name: str
        mode: str
        path: str

    @staticmethod
    def _run(command: List[str]) -> Tuple[str, int]:
        """
        Run a command and return (stdout+stderr, returncode).
        """
        proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, _ = proc.communicate()
        return stdout.decode().strip(), proc.returncode

    def get_selections(self, expr: str = None) -> List["UpdateAlternative.Selection"]:
        """
        Return the list of alternative selections, optionally filtered.

        Without *expr*, returns every entry from ``--get-selections``.
        With *expr*, keeps entries whose full line matches *expr* as a
        case-insensitive substring **or** as a Python regex.  If the
        regex is invalid, only substring matching is used.

        Each line has three fields: (name, mode, alternative).
        The filter matches against the whole line, so you can search by
        name (e.g. "blas"), mode (e.g. "manual"), or path
        (e.g. "libmkl_rt").
        """
        output, retcode = self._run([self._CMD, "--get-selections"])
        if retcode:
            return []

        lines = [line for line in output.split("\n") if line.strip()]

        if expr is not None:
            try:
                lines = [
                    line
                    for line in lines
                    if expr.lower() in line.lower() or re.match(expr, line)
                ]
            except re.error:
                lines = [line for line in lines if expr.lower() in line.lower()]

        selections = []
        for line in lines:
            parts = line.split()
            if len(parts) >= 3:
                selections.append(self.Selection(parts[0], parts[1], parts[2]))
            elif len(parts) == 2:
                selections.append(self.Selection(parts[0], parts[1], ""))
        return selections

    def query(self, name: str) -> Tuple[Dict, List[Dict]]:
        """
        Run ``--query NAME`` and parse the DEB-822 output.

        Returns (info, candidates) where *info* is a dict of the
        top-level fields (Name, Link, Status, Best, Value) and
        *candidates* is a list of dicts with Alternative, Priority,
        and optionally Slaves.

        Raises RuntimeError if the query command fails.
        """
        output, retcode = self._run([self._CMD, "--query", name])
        if retcode:
            raise RuntimeError(f"{self._CMD} --query {name!r} failed (exit {retcode})")
        parsed = deb822parse(output.split("\n"))
        info, candidates = parsed[0], parsed[1:]
        return info, candidates

    def set(self, name: str, selection: str) -> Tuple[str, int]:
        """
        Change the alternative for *name*.

        If *selection* is ``"auto"``, uses ``--auto``; otherwise uses
        ``--set`` with the given path.

        Returns (message, returncode).
        """
        if selection == "auto":
            return self._run([self._CMD, "--auto", name])
        return self._run([self._CMD, "--set", name, selection])


# -- urwid palette --
# (name, foreground, background, mono, foreground_high, background_high)
PALETTE = [
    ("nor", "white,bold", "black"),  # normal status
    ("err", "white,bold", "dark red"),  # error status
    ("suc", "white,bold", "dark green"),  # success status
    ("vio", "light magenta,bold", "black"),  # violet status
    ("wrn", "yellow,bold", "black"),  # warning status
    ("cur", "white", "dark blue"),  # focused cursor
    ("idl", "light blue,bold", "black"),  # idle cursor (unfocused pane)
    ("normal", "white", "black"),  # normal text
    ("divider", "white", "black"),  # split lines
    ("pri", "dark cyan", "black"),  # priority number
    ("path", "light green", "black"),  # file paths
    ("label", "yellow,bold", "black"),  # section labels
    ("sel", "light green,bold", "black"),  # [*] selected marker
    ("unsel", "dark gray", "black"),  # [ ] unselected marker
    ("slave_name", "light cyan", "black"),  # slave link name
]


class SelectBox(urwid.Widget):
    """
    Selection box as a custom urwid box widget.
    Replicates the original termbox frame-based scrolling: the visible
    window stays fixed and only shifts when the cursor reaches the edge.
    """

    _sizing = frozenset([urwid.BOX])
    _selectable = False

    # Markup attribute names used in colorized choices
    _MARKUP_ATTRS = ("pri", "path", "label", "sel", "unsel", "slave_name")
    # Attr maps that override all markup colors for cursor lines
    _CURSOR_ATTR_MAP = {None: "cur"}
    _IDLE_ATTR_MAP = {None: "idl"}
    for _a in _MARKUP_ATTRS:
        _CURSOR_ATTR_MAP[_a] = "cur"
        _IDLE_ATTR_MAP[_a] = "idl"

    def __init__(self, label="", wrap="clip"):
        super().__init__()
        self._focused = False
        self.choices = [""]
        self.cursor = 0
        self._frame_start = 0
        self.label = label
        self._wrap = wrap

    @staticmethod
    def _plain_text(markup):
        """Extract plain text from a urwid text markup item."""
        if isinstance(markup, str):
            return markup
        if isinstance(markup, tuple):
            # (attr, text_or_markup)
            return SelectBox._plain_text(markup[1])
        if isinstance(markup, list):
            return "".join(SelectBox._plain_text(part) for part in markup)
        return str(markup)

    def reset(self, choices):
        self.choices = choices if choices else [""]
        self.cursor = 0
        self._frame_start = 0
        self._invalidate()

    def set_cursor(self, idx):
        self.cursor = max(0, min(idx, len(self.choices) - 1))
        self._invalidate()

    def focus(self, state=None):
        if state is None:
            return self._focused
        self._focused = state
        self._invalidate()

    def get(self, name=None):
        if name == "cursor":
            return self.cursor
        if not self.choices:
            return ""
        return self._plain_text(self.choices[self.cursor])

    def next(self):
        if self.cursor < len(self.choices) - 1:
            self.cursor += 1
            self._invalidate()

    def prev(self):
        if self.cursor > 0:
            self.cursor -= 1
            self._invalidate()

    def render(self, size, focus=False):
        maxcol, maxrow = size

        if self._wrap != "clip":
            # Wrapping mode: each item may span multiple display rows
            canvases = []
            total_rows = 0
            for idx in range(self._frame_start, len(self.choices)):
                if total_rows >= maxrow:
                    break
                line = self.choices[idx]
                if idx == self.cursor:
                    attr_map = (
                        self._CURSOR_ATTR_MAP if self._focused else self._IDLE_ATTR_MAP
                    )
                else:
                    attr_map = {None: "normal"}
                text_w = urwid.AttrMap(urwid.Text(line, wrap=self._wrap), attr_map)
                canvas = text_w.render((maxcol,))
                rows = canvas.rows()
                if total_rows + rows > maxrow:
                    remaining = maxrow - total_rows
                    if remaining > 0:
                        canvas = urwid.CompositeCanvas(canvas)
                        canvas.pad_trim_top_bottom(0, -(rows - remaining))
                        canvases.append((canvas, None, False))
                        total_rows += remaining
                    break
                canvases.append((canvas, None, False))
                total_rows += rows
            # Fill remaining space with blanks
            if total_rows < maxrow:
                blank = urwid.AttrMap(urwid.Text(""), "normal").render((maxcol,))
                for _ in range(maxrow - total_rows):
                    canvases.append((blank, None, False))
            if not canvases:
                return urwid.SolidCanvas(" ", maxcol, maxrow)
            return urwid.CanvasCombine(canvases)

        # Clip mode: frame-based scrolling, one row per item
        if self.cursor >= self._frame_start + maxrow:
            self._frame_start = self.cursor - maxrow + 1
        if self.cursor < self._frame_start:
            self._frame_start = self.cursor

        canvases = []
        for i in range(maxrow):
            idx = self._frame_start + i
            if idx < len(self.choices):
                line = self.choices[idx]
                if idx == self.cursor:
                    attr_map = (
                        self._CURSOR_ATTR_MAP if self._focused else self._IDLE_ATTR_MAP
                    )
                else:
                    attr_map = {None: "normal"}
            else:
                line = ""
                attr_map = {None: "normal"}
            text_w = urwid.AttrMap(urwid.Text(line, wrap="clip"), attr_map)
            canvases.append((text_w.render((maxcol,)), None, False))

        if not canvases:
            return urwid.SolidCanvas(" ", maxcol, maxrow)
        return urwid.CanvasCombine(canvases)


class Rover(object):
    """
    Text-based light-weight frontend to update-alternatives.
    """

    def __init__(self, expression=None):
        # Get U-A selections
        self.ua = UpdateAlternative()
        self.selections = list(sorted(self.ua.get_selections(), key=lambda s: s.name))

        if not self.selections:
            print(
                "No alternatives found. "
                "Are you sure update-alternatives is available?"
            )
            sys.exit(1)

        # Create widgets
        self.lp = SelectBox("lpane")
        self.rp = SelectBox("rpane")
        self.ip = SelectBox("ipane", wrap="space")

        # Status bar
        self._status_text = urwid.Text(("vio", f"Rover {__VERSION__} by {__AUTHOR__}"))
        self.status_widget = urwid.AttrMap(self._status_text, "vio")

        # Initialize left pane
        self.lp.reset(list(sorted(s.name for s in self.selections)))
        self.lp.focus(True)
        self.FOCUS = "lp"
        self.rp.reset([""])
        self.ip.reset([""])

        # Parse the selection in left pane, then update right pane
        self.update_rp()
        self.update_ip()

        # State for regex input
        self.state_input = False
        self.regex = ""
        self.status = ""

        # Apply filter if given
        if expression:
            self.reload_selections(expression)
            self.update_rp()
            self.update_ip()

        # Build layout
        self._build_layout()

    def _build_layout(self):
        """Build the urwid widget tree matching the original 3-pane layout."""
        # Right side: rpane on top, ipane on bottom, separated by a divider
        hsplit = urwid.AttrMap(urwid.Divider("─"), "divider")
        right_pile = urwid.Pile(
            [
                ("weight", 1, self.rp),
                ("pack", hsplit),
                ("weight", 1, self.ip),
            ]
        )

        # Vertical divider column (1 char wide)
        vfill = urwid.AttrMap(urwid.SolidFill("│"), "divider")

        # Columns: lpane | vdivider | right_pile
        # Left pane gets 24/80 ≈ 30% of width
        columns = urwid.Columns(
            [
                ("weight", 24, self.lp),
                (1, vfill),
                ("weight", 56, right_pile),
            ]
        )

        # Main frame: columns on top, status bar at bottom
        self.frame = urwid.Frame(
            body=columns,
            footer=self.status_widget,
        )

    def _set_status(self, style, msg):
        """Set the status bar text with the given style."""
        self._status_text.set_text((style, msg))

    def status_hint(self):
        """display keybinding hint in status bar"""
        msg = "[↓] j,↓  [↑] k,↑ [←] h,← [→] l,→ [*] SPACE,ENTER [?] /,? [X] q,ESC"
        self._set_status("wrn", msg)

    def reload_selections(self, regex=None):
        """
        Reload "update-alternatives --get-selections"
        And filter contents in the left side pane.
        """
        selections = list(sorted(self.ua.get_selections(regex), key=lambda s: s.name))
        if len(selections) == 0:
            self._set_status("err", "Invalid filter expression!")
            return
        self.selections = selections
        self.lp.reset(list(sorted(s.name for s in self.selections)))
        self.lp.focus(self.FOCUS == "lp")

    def update_rp(self):
        """
        Query the status of candidates, using the selection of left pane
        parse the query result of UpdateAlternative.query(...)
        then update the right pane
        """
        sel = self.selections[self.lp.get("cursor")]
        assert sel.name == self.lp.get()
        info, candidates = self.ua.query(sel.name)
        # prepare contents for rpane
        padding = max(len(cand["Priority"]) for cand in candidates)
        rpl, rp_cursor = [], 0
        if "auto" == info["Status"]:
            rpl.append([("sel", "[*]"), " ", ("pri", "?".ljust(padding)), " ", "auto"])
        else:
            rpl.append(
                [("unsel", "[ ]"), " ", ("pri", "?".ljust(padding)), " ", "auto"]
            )
        for i, cand in enumerate(candidates, 1):
            alt, pri = cand["Alternative"], cand["Priority"]
            if alt == info["Value"] and "auto" != info["Status"]:
                rpl.append(
                    [
                        ("sel", "[*]"),
                        " ",
                        ("pri", pri.ljust(padding)),
                        " ",
                        ("path", alt),
                    ]
                )
                rp_cursor = i
            else:
                rpl.append(
                    [
                        ("unsel", "[ ]"),
                        " ",
                        ("pri", pri.ljust(padding)),
                        " ",
                        ("path", alt),
                    ]
                )
        self.rp.reset(rpl)
        self.rp.set_cursor(rp_cursor)
        self.rp.focus(self.FOCUS == "rp")

    def update_ip(self):
        sel = self.selections[self.lp.get("cursor")]
        assert sel.name == self.lp.get()
        info, candidates = self.ua.query(sel.name)
        iselection = self.rp.get().split()[-1]
        # prepare contents for ipane
        ipl = []
        if iselection == "auto":
            idict = [x for x in candidates if x["Alternative"] == sel.path][0]
        else:
            idict = [x for x in candidates if x["Alternative"] == iselection][0]
        ipl.append(("label", "Alternative"))
        ipl.append(("path", idict["Alternative"]))
        ipl.append("")
        ipl.append([("label", "Priority: "), ("pri", str(idict["Priority"]))])
        ipl.append("")
        slaves = idict.get("Slaves", [])
        if slaves:
            ipl.append(("label", "Slaves"))
            slave_list = [slaves] if isinstance(slaves, str) else slaves
            for slave in slave_list:
                parts = slave.split(None, 1)
                if len(parts) == 2:
                    ipl.append([("slave_name", parts[0]), " ", ("path", parts[1])])
                else:
                    ipl.append(("path", slave))
        self.ip.reset(ipl)

    def move_up(self):
        if self.FOCUS == "lp":
            self.lp.prev()
            self._set_status("nor", " │ ".join(self.selections[self.lp.get("cursor")]))
            self.update_rp()
            self.update_ip()
        else:
            self.rp.prev()
            selection = self.rp.get()
            self._set_status("wrn", f"*? {selection}")
            self.update_ip()

    def move_dn(self):
        if self.FOCUS == "lp":
            self.lp.next()
            self._set_status("nor", " │ ".join(self.selections[self.lp.get("cursor")]))
            self.update_rp()
            self.update_ip()
        else:
            self.rp.next()
            selection = self.rp.get()
            self._set_status("wrn", f"*? {selection}")
            self.update_ip()

    def move_left(self):
        self.FOCUS = "lp"
        self.lp.focus(True)
        self.rp.focus(False)

    def move_right(self):
        self.FOCUS = "rp"
        self.rp.focus(True)
        self.lp.focus(False)

    def set(self):
        """
        change alternatives setting via UpdateAlternative.set(...)
        """
        if self.FOCUS == "lp":
            self.status_hint()
            return
        name = self.selections[self.lp.get("cursor")].name
        sele = self.rp.get().split()[-1]
        msg, code = self.ua.set(name, sele)
        if code == 2:
            self._set_status("err", "Permission Denied. Are you root?")
        else:
            self._set_status("suc", f"{name} -> {sele}")
        self.update_rp()
        self.update_ip()

    def handle_input(self, key):
        """Handle all keyboard input. Called by urwid's unhandled_input."""
        # --- Regex input mode ---
        if self.state_input:
            if key == "enter":
                self.state_input = False
                self.reload_selections(self.regex)
                self.update_rp()
                self.update_ip()
            elif key == "esc":
                self.state_input = False
                self.status_hint()
            elif key == "backspace":
                self.regex = self.regex[:-1]
                self.status = "?> " + self.regex
                self._set_status("vio", self.status)
            elif len(key) == 1:
                self.regex += key
                self.status += key
                self._set_status("vio", self.status)
            return

        # --- Normal mode ---
        if key in ("q", "Q", "esc"):
            raise urwid.ExitMainLoop()
        elif key in ("j", "down"):
            self.move_dn()
        elif key in ("k", "up"):
            self.move_up()
        elif key in ("h", "left"):
            self.move_left()
        elif key in ("l", "right"):
            self.move_right()
        elif key in ("/", "?"):
            self.state_input = True
            self.status, self.regex = "?> ", ""
            self._set_status("vio", self.status)
        elif key == "enter":
            self.set()
        elif key == " ":
            self.set()
        else:
            self.status_hint()


if __name__ == "__main__":
    ag = argparse.ArgumentParser()
    ag.add_argument(
        "-e", "--expression", type=str, default="", help="Filter the alternatives list"
    )
    ag.add_argument(
        "-v", "--version", action="store_true", help="Print version information"
    )
    ag = ag.parse_args()

    if ag.version:
        Version()
        exit()

    rv = Rover(expression=ag.expression if ag.expression else None)

    loop = urwid.MainLoop(
        rv.frame,
        palette=PALETTE,
        unhandled_input=rv.handle_input,
    )
    loop.run()
