#!/usr/bin/env python3
import curses
import os
import re
import shlex
import subprocess
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple

APP_DIRS = [
    Path.home() / ".local/share/flatpak/exports/share/applications",
    Path("/var/lib/flatpak/exports/share/applications"),
]
BIN_DIR = Path("/usr/local/bin")
ALIAS_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
MANAGED_HEADER = "# flatpak-tui-wrapper"


@dataclass
class DesktopEntry:
    appid: str
    name: str
    name_local: str
    exec_line: str
    nodisplay: bool
    desktop_file: Path
    desktop_cmd: str
    exec_appid: str


@dataclass
class FlatpakApp:
    appid: str
    installation: str
    name: str
    desktop_cmd: str
    suggested_alias: str
    launch_cmd: List[str]
    alias_exists: bool
    desktop_file: str


class AppError(RuntimeError):
    pass


def run(cmd: List[str]) -> str:
    return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)


def parse_flatpak_list() -> List[Tuple[str, str]]:
    try:
        raw = run(["flatpak", "list", "--app", "--columns=application,installation"])
    except Exception as e:
        raise RuntimeError("flatpak non disponibile o non funzionante") from e

    apps: List[Tuple[str, str]] = []
    for line in raw.splitlines():
        line = line.strip()
        if not line:
            continue
        lower = line.lower()
        if lower.startswith("application"):
            continue
        parts = line.split()
        if not parts:
            continue
        appid = parts[0]
        installation = parts[1] if len(parts) > 1 else "system"
        if "." not in appid:
            continue
        apps.append((appid, installation))
    return apps


def parse_exec(exec_line: str) -> Tuple[str, str]:
    cmd = ""
    appid = ""
    try:
        tokens = shlex.split(exec_line)
    except Exception:
        tokens = exec_line.split()

    clean = [t for t in tokens if not t.startswith("%") and not t.startswith("@@")]
    if not clean:
        return cmd, appid

    flatpak_pos = None
    for i, tok in enumerate(clean):
        base = os.path.basename(tok)
        if tok == "flatpak" or base == "flatpak":
            flatpak_pos = i
            break

    if flatpak_pos is not None:
        after = clean[flatpak_pos + 1 :]
        try:
            run_idx = after.index("run")
            after = after[run_idx + 1 :]
        except ValueError:
            pass
        for tok in after:
            if tok.startswith("--command="):
                cmd = tok.split("=", 1)[1]
                continue
            if tok.startswith("-"):
                continue
            appid = tok
            break
    else:
        cmd = os.path.basename(clean[0])
    return cmd, appid


def read_desktop_file(path: Path) -> Optional[DesktopEntry]:
    name = ""
    name_local = ""
    exec_line = ""
    nodisplay = False
    in_entry = False
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except Exception:
        return None
    for raw in text.splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("["):
            in_entry = line == "[Desktop Entry]"
            continue
        if not in_entry or "=" not in line:
            continue
        k, v = line.split("=", 1)
        if k == "Name":
            name = v.strip()
        elif k.startswith("Name[") and not name_local:
            name_local = v.strip()
        elif k == "Exec":
            exec_line = v.strip()
        elif k == "NoDisplay":
            nodisplay = v.strip().lower() == "true"
    cmd, exec_appid = parse_exec(exec_line)
    appid = path.stem
    return DesktopEntry(
        appid=appid,
        name=name,
        name_local=name_local,
        exec_line=exec_line,
        nodisplay=nodisplay,
        desktop_file=path,
        desktop_cmd=cmd,
        exec_appid=exec_appid,
    )


def scan_desktop_entries() -> Dict[str, List[DesktopEntry]]:
    out: Dict[str, List[DesktopEntry]] = {}
    for base in APP_DIRS:
        if not base.is_dir():
            continue
        for path in base.glob("*.desktop"):
            entry = read_desktop_file(path)
            if not entry:
                continue
            key = entry.exec_appid or entry.appid
            out.setdefault(key, []).append(entry)
    return out


def slugify(text: str) -> str:
    text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
    text = text.lower()
    text = re.sub(r"[^a-z0-9_-]+", "-", text)
    text = re.sub(r"-+", "-", text).strip("-")
    if not text:
        text = "fp-app"
    if not re.match(r"^[a-z_]", text):
        text = f"fp-{text}"
    return text


def choose_entry(appid: str, entries: Dict[str, List[DesktopEntry]]) -> Optional[DesktopEntry]:
    options = entries.get(appid, [])
    if not options:
        return None

    def score(e: DesktopEntry) -> Tuple[int, int, int]:
        return (
            0 if e.nodisplay else 1,
            1 if e.desktop_cmd else 0,
            1 if (e.exec_appid == appid or e.appid == appid) else 0,
        )

    options = sorted(options, key=score, reverse=True)
    return options[0]


def wrapper_path(alias: str) -> Path:
    return BIN_DIR / alias


def wrapper_exists(alias: str) -> bool:
    return wrapper_path(alias).exists()


def is_managed_wrapper(path: Path) -> bool:
    try:
        with path.open("r", encoding="utf-8", errors="replace") as f:
            first = f.readline().strip()
            second = f.readline().strip()
        return first == "#!/bin/sh" and second == MANAGED_HEADER
    except Exception:
        return False


def build_apps() -> List[FlatpakApp]:
    desktop_entries = scan_desktop_entries()
    apps: List[FlatpakApp] = []
    seen = set()
    for appid, installation in parse_flatpak_list():
        key = (appid, installation)
        if key in seen:
            continue
        seen.add(key)
        entry = choose_entry(appid, desktop_entries)
        name = appid.split(".")[-1]
        desktop_cmd = ""
        desktop_file = ""
        launch_cmd: List[str] = ["flatpak", "run", appid]
        if entry:
            name = entry.name_local or entry.name or name
            desktop_cmd = entry.desktop_cmd
            desktop_file = str(entry.desktop_file)
            if desktop_cmd:
                launch_cmd = ["flatpak", "run", f"--command={desktop_cmd}", appid]
        alias_name = desktop_cmd if ALIAS_RE.match(desktop_cmd or "") else slugify(name)
        apps.append(
            FlatpakApp(
                appid=appid,
                installation=installation,
                name=name,
                desktop_cmd=desktop_cmd,
                suggested_alias=alias_name,
                launch_cmd=launch_cmd,
                alias_exists=wrapper_exists(alias_name),
                desktop_file=desktop_file,
            )
        )
    apps.sort(key=lambda a: (a.name.lower(), a.appid.lower(), a.installation))
    return apps


def shell_quote_join(parts: List[str]) -> str:
    return " ".join(shlex.quote(p) for p in parts)


def save_wrapper(alias: str, app: FlatpakApp) -> None:
    command = shell_quote_join(app.launch_cmd)
    content = (
        "#!/bin/sh\n"
        f"{MANAGED_HEADER}\n"
        f"# appid={app.appid}\n"
        f"# name={app.name}\n"
        f"exec {command} \"$@\"\n"
    )
    try:
        subprocess.run(
            ["sudo", "tee", str(wrapper_path(alias))],
            input=content,
            text=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
            check=True,
        )
        subprocess.run(
            ["sudo", "chmod", "755", str(wrapper_path(alias))],
            text=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        err = (e.stderr or "").strip()
        raise AppError(err or "scrittura wrapper fallita")


def delete_wrapper(alias: str) -> bool:
    path = wrapper_path(alias)
    if not path.exists():
        return False
    if not is_managed_wrapper(path):
        raise AppError(f"{path} esiste ma non è stato creato dal tool")
    try:
        subprocess.run(
            ["sudo", "rm", "-f", str(path)],
            text=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        err = (e.stderr or "").strip()
        raise AppError(err or "eliminazione wrapper fallita")
    return True


def confirm_sudo() -> None:
    try:
        subprocess.run(
            ["sudo", "-v"],
            text=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
            check=True,
        )
    except subprocess.CalledProcessError as e:
        err = (e.stderr or "").strip()
        raise AppError(err or "autorizzazione sudo fallita")


def prompt(stdscr, message: str, initial: str = "") -> Optional[str]:
    curses.curs_set(1)
    h, w = stdscr.getmaxyx()
    value = list(initial)
    pos = len(value)
    while True:
        stdscr.move(h - 2, 0)
        stdscr.clrtoeol()
        text = f"{message}{''.join(value)}"
        stdscr.addnstr(h - 2, 0, text, w - 1)
        stdscr.move(h - 2, min(len(message) + pos, w - 2))
        ch = stdscr.get_wch()
        if ch in ("\n", "\r"):
            curses.curs_set(0)
            return "".join(value).strip()
        if ch == "\x1b":
            curses.curs_set(0)
            return None
        if ch in (curses.KEY_BACKSPACE, "\b", "\x7f"):
            if pos > 0:
                pos -= 1
                value.pop(pos)
            continue
        if ch == curses.KEY_DC:
            if pos < len(value):
                value.pop(pos)
            continue
        if ch == curses.KEY_LEFT:
            pos = max(0, pos - 1)
            continue
        if ch == curses.KEY_RIGHT:
            pos = min(len(value), pos + 1)
            continue
        if isinstance(ch, str) and ch.isprintable():
            value.insert(pos, ch)
            pos += 1


def status_line(stdscr, text: str) -> None:
    h, w = stdscr.getmaxyx()
    stdscr.move(h - 1, 0)
    stdscr.clrtoeol()
    stdscr.addnstr(h - 1, 0, text, w - 1, curses.A_REVERSE)


def draw(stdscr, apps: List[FlatpakApp], idx: int, top: int, query: str, msg: str) -> None:
    stdscr.erase()
    h, w = stdscr.getmaxyx()
    title = "Flatpak Wrapper TUI  q=esci  / =cerca  a=crea wrapper  d=elimina wrapper  r=ricarica"
    stdscr.addnstr(0, 0, title, w - 1, curses.A_BOLD)
    if query:
        stdscr.addnstr(1, 0, f"Filtro: {query}", w - 1)
    header_row = 2
    stdscr.addnstr(header_row, 0, "Nome", min(28, w - 1), curses.A_UNDERLINE)
    stdscr.addnstr(header_row, 30, "Comando", min(18, max(0, w - 31)), curses.A_UNDERLINE)
    stdscr.addnstr(header_row, 50, "Install", min(8, max(0, w - 51)), curses.A_UNDERLINE)
    stdscr.addnstr(header_row, 60, "AppID", max(0, w - 61), curses.A_UNDERLINE)

    visible_height = max(1, h - 5)
    for row in range(visible_height):
        i = top + row
        if i >= len(apps):
            break
        y = header_row + 1 + row
        app = apps[i]
        attr = curses.A_REVERSE if i == idx else curses.A_NORMAL
        alias = app.suggested_alias + (" *" if app.alias_exists else "")
        stdscr.addnstr(y, 0, app.name, min(28, w - 1), attr)
        if w > 30:
            stdscr.addnstr(y, 30, alias, min(18, w - 31), attr)
        if w > 50:
            stdscr.addnstr(y, 50, app.installation, min(8, w - 51), attr)
        if w > 60:
            stdscr.addnstr(y, 60, app.appid, max(0, w - 61), attr)

    if apps:
        cur = apps[idx]
        launch = " ".join(cur.launch_cmd)
        msg = f"{msg} | lancia: {launch}" if msg else f"lancia: {launch}"
    status_line(stdscr, msg or " ")
    stdscr.refresh()


def filtered_apps(apps: List[FlatpakApp], query: str) -> List[FlatpakApp]:
    q = query.strip().lower()
    if not q:
        return apps
    return [
        a
        for a in apps
        if q in a.name.lower() or q in a.appid.lower() or q in a.suggested_alias.lower() or q in a.desktop_cmd.lower()
    ]


def main_tui(stdscr) -> int:
    curses.use_default_colors()
    curses.curs_set(0)
    stdscr.keypad(True)

    all_apps = build_apps()
    query = ""
    apps = filtered_apps(all_apps, query)
    idx = 0
    top = 0
    msg = f"{len(apps)} app trovate"

    while True:
        if idx >= len(apps):
            idx = max(0, len(apps) - 1)
        h, _ = stdscr.getmaxyx()
        visible_height = max(1, h - 5)
        if idx < top:
            top = idx
        if idx >= top + visible_height:
            top = idx - visible_height + 1
        draw(stdscr, apps, idx, top, query, msg)
        msg = ""
        ch = stdscr.get_wch()

        if ch in ("q", "Q"):
            return 0
        if ch in ("j", curses.KEY_DOWN):
            if idx < len(apps) - 1:
                idx += 1
            continue
        if ch in ("k", curses.KEY_UP):
            if idx > 0:
                idx -= 1
            continue
        if ch == curses.KEY_NPAGE:
            idx = min(len(apps) - 1, idx + visible_height)
            continue
        if ch == curses.KEY_PPAGE:
            idx = max(0, idx - visible_height)
            continue
        if ch in ("/",):
            res = prompt(stdscr, "cerca: ", query)
            if res is not None:
                query = res
                apps = filtered_apps(all_apps, query)
                idx = 0
                top = 0
                msg = f"{len(apps)} risultati"
            continue
        if ch in ("r", "R"):
            all_apps = build_apps()
            apps = filtered_apps(all_apps, query)
            idx = 0
            top = 0
            msg = f"ricaricate: {len(apps)} app"
            continue
        if not apps:
            continue
        current = apps[idx]
        if ch in ("a", "A", "\n", "\r"):
            name = prompt(stdscr, "wrapper in /usr/local/bin: ", current.suggested_alias)
            if not name:
                msg = "annullato"
                continue
            if not ALIAS_RE.match(name):
                msg = "nome wrapper non valido"
                continue
            target = wrapper_path(name)
            if target.exists() and not is_managed_wrapper(target):
                msg = f"esiste già e non è gestito: {target}"
                continue
            if target.exists() and name != current.suggested_alias:
                ans = prompt(stdscr, f"{name} esiste, sovrascrivere? [y/N] ", "n")
                if not ans or ans.lower() != "y":
                    msg = "non sovrascritto"
                    continue
            try:
                confirm_sudo()
                save_wrapper(name, current)
                all_apps = build_apps()
                apps = filtered_apps(all_apps, query)
                idx = min(idx, len(apps) - 1)
                msg = f"wrapper creato: {name}"
            except AppError as e:
                msg = str(e)
            continue
        if ch in ("d", "D"):
            target = prompt(stdscr, "elimina wrapper: ", current.suggested_alias)
            if not target:
                msg = "annullato"
                continue
            try:
                confirm_sudo()
                if delete_wrapper(target):
                    all_apps = build_apps()
                    apps = filtered_apps(all_apps, query)
                    idx = min(idx, len(apps) - 1)
                    msg = f"wrapper eliminato: {target}"
                else:
                    msg = f"wrapper non trovato: {target}"
            except AppError as e:
                msg = str(e)
            continue


def main() -> int:
    if len(sys.argv) > 1 and sys.argv[1] == "--list":
        for app in build_apps():
            mark = "*" if app.alias_exists else ""
            print(f"{app.name}\t{app.suggested_alias}{mark}\t{app.installation}\t{app.appid}")
        return 0
    return curses.wrapper(main_tui)


if __name__ == "__main__":
    raise SystemExit(main())
