#!/usr/bin/env python3
"""termtorrent - an ncurses front-end for rtorrent.

Talks to rtorrent over its SCGI socket using XML-RPC, and puts the common
actions on a toolbar: add, start, stop, remove, details, limits, refresh.
"""

import curses
import os
import shutil
import socket
import sys
import xmlrpc.client

VERSION = "0.1"
SOCKET = os.environ.get("RTORRENT_SCGI") or os.path.expanduser("~/.rtorrent-scgi.sock")

FIELDS = [
    "d.hash=",
    "d.name=",
    "d.size_bytes=",
    "d.completed_bytes=",
    "d.down.rate=",
    "d.up.rate=",
    "d.ratio=",
    "d.state=",
    "d.is_active=",
]


class RPC:
    def __init__(self, path):
        self.path = path
        self.timeout = 5

    def call(self, method, *params):
        body = xmlrpc.client.dumps(params, methodname=method, allow_none=True).encode()
        head = b"CONTENT_LENGTH\x00" + str(len(body)).encode() + b"\x00SCGI\x001\x00"
        req = str(len(head)).encode() + b":" + head + b"," + body
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.settimeout(self.timeout)
        try:
            sock.connect(self.path)
            sock.sendall(req)
            chunks = []
            while True:
                data = sock.recv(65536)
                if not data:
                    break
                chunks.append(data)
        finally:
            sock.close()
        raw = b"".join(chunks)
        _, _, payload = raw.partition(b"\r\n\r\n")
        return xmlrpc.client.loads(payload)[0][0]


def human_size(n):
    n = float(n)
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024 or unit == "TB":
            return ("%.1f %s" % (n, unit)) if unit != "B" else ("%d B" % n)
        n /= 1024


def human_rate(n):
    return human_size(n) + "/s"


class App:
    def __init__(self, stdscr):
        self.scr = stdscr
        self.rpc = RPC(SOCKET)
        self.color = curses.has_colors()
        self.rows = []
        self.sel = 0
        self.scroll = 0
        self.focus = "table"
        self.button = 0
        self.message = ""
        self.buttons = [
            ("Add", "add"),
            ("Start", "start"),
            ("Stop", "stop"),
            ("Remove", "remove"),
            ("Details", "details"),
            ("Limits", "limits"),
            ("Refresh", "refresh"),
            ("Quit", "quit"),
        ]
        self.button_xs = []
        self.version = "?"

    # ---------- drawing ----------
    def size(self):
        return self.scr.getmaxyx()

    def draw_bar(self, y, text, focus=False, right=""):
        _, w = self.size()
        if self.color:
            attr = curses.color_pair(1) | (curses.A_BOLD if focus else 0)
        else:
            attr = curses.A_REVERSE
        avail = max(0, w - 1 - (len(right) + 1 if right else 0))
        self.scr.addnstr(y, 0, text[:avail].ljust(avail), avail, attr)
        if right:
            self.scr.addnstr(y, w - 1 - len(right), right, len(right), attr | curses.A_BOLD)

    def draw_buttons(self):
        _, w = self.size()
        x = 0
        self.button_xs = []
        base = curses.color_pair(2) if self.color else curses.A_DIM
        self.scr.addnstr(1, 0, " " * (w - 1), w - 1, base)
        for i, (label, _) in enumerate(self.buttons):
            self.button_xs.append(x)
            chunk = " %s " % label
            if i == self.button and self.focus == "buttons":
                attr = curses.color_pair(3) if self.color else curses.A_REVERSE
            else:
                attr = base
            self.scr.addnstr(1, x, chunk, max(0, w - 1 - x), attr)
            x += len(chunk) + 1

    def draw_table(self):
        h, w = self.size()
        self.scr.move(2, 0)
        self.scr.clrtobot()
        header = "%-28s %9s %5s %9s %9s %5s %-8s" % (
            "Name", "Size", "Done", "Down", "Up", "Ratio", "State")
        self.scr.addnstr(2, 0, header[: w - 1], w - 1, curses.A_BOLD)
        visible = max(1, h - 5)
        if self.sel < self.scroll:
            self.scroll = self.sel
        if self.sel >= self.scroll + visible:
            self.scroll = self.sel - visible + 1
        for i in range(visible):
            idx = self.scroll + i
            if idx >= len(self.rows):
                break
            row = self.rows[idx]
            done = 0 if not row["size"] else int(100 * row["done"] / row["size"])
            ratio = row["ratio"] / 1000.0
            if not row["active"]:
                state = "stopped"
            elif row["size"] and row["done"] >= row["size"]:
                state = "seed"
            else:
                state = "active"
            line = "%-28s %9s %4d%% %9s %9s %5.2f %-8s" % (
                row["name"][:28], human_size(row["size"]), done,
                human_rate(row["down"]), human_rate(row["up"]), ratio, state)
            attr = curses.A_REVERSE if idx == self.sel else curses.A_NORMAL
            self.scr.addnstr(3 + i, 0, line[: w - 1], w - 1, attr)

    def totals(self):
        down = sum(r["down"] for r in self.rows)
        up = sum(r["up"] for r in self.rows)
        return down, up

    def draw_status(self):
        h, w = self.size()
        down, up = self.totals()
        focus = {"table": "torrents", "buttons": "buttons"}[self.focus]
        msg = self.message or "Tab: %s | a: add | s/S: start/stop | d: remove | Enter: details | ?: help | q: quit" % focus
        status = " %s  [%d/%d]  D:%s U:%s" % (msg, self.sel + 1 if self.rows else 0, len(self.rows), human_rate(down), human_rate(up))
        self.scr.move(h - 1, 0)
        self.scr.clrtoeol()
        attr = curses.color_pair(1) if self.color else curses.A_REVERSE
        self.scr.addnstr(h - 1, 0, status.ljust(w - 1), w - 1, attr)
        self.message = ""

    def redraw(self):
        self.scr.erase()
        down, up = self.totals()
        self.draw_bar(0, " rTorrent %s  -  termtorrent" % self.version, True, " D:%s  U:%s " % (human_rate(down), human_rate(up)))
        self.draw_buttons()
        self.draw_table()
        self.draw_status()
        self.scr.refresh()

    # ---------- data ----------
    def refresh(self):
        try:
            data = self.rpc.call("d.multicall2", "", "", *FIELDS)
        except Exception as exc:  # noqa: BLE001
            self.rows = []
            self.message = "rTorrent not reachable (%s)" % exc
            return
        rows = []
        for rec in data:
            rows.append({
                "hash": rec[0], "name": rec[1], "size": int(rec[2]),
                "done": int(rec[3]), "down": int(rec[4]), "up": int(rec[5]),
                "ratio": int(rec[6]), "state": int(rec[7]), "active": int(rec[8]),
            })
        rows.sort(key=lambda r: r["name"].lower())
        self.rows = rows
        if self.sel >= len(rows):
            self.sel = max(0, len(rows) - 1)

    def selected(self):
        if not self.rows:
            return None
        return self.rows[self.sel]

    # ---------- dialogs ----------
    def input_box(self, title, initial=""):
        h, w = self.size()
        win_w = min(w - 4, 70)
        win = curses.newwin(5, win_w, max(1, h // 2 - 2), max(1, (w - win_w) // 2))
        win.keypad(True)
        text = list(initial)
        pos = len(text)
        first = True
        while True:
            win.erase()
            win.box()
            win.addnstr(0, 2, " %s " % title, win_w - 4, curses.A_BOLD)
            shown = "".join(text)[-(win_w - 4):]
            win.addnstr(2, 2, shown, win_w - 4)
            hint = "Enter: ok   Esc: cancel"
            win.addnstr(3, 2, hint, win_w - 4, curses.A_DIM)
            win.move(2, 2 + min(pos, win_w - 5))
            win.refresh()
            key = win.getch()
            if key in (10, 13, curses.KEY_ENTER):
                return "".join(text)
            if key == 27:
                return None
            if key in (curses.KEY_BACKSPACE, 127, 8):
                if first:
                    text = []
                    first = False
                elif text:
                    text.pop()
                pos = len(text)
            elif key == 21:
                text = []
                first = False
                pos = 0
            elif 32 <= key < 127:
                if first:
                    text = []
                    first = False
                text.append(chr(key))
                pos = len(text)

    def confirm(self, title, question):
        h, w = self.size()
        win_w = min(w - 4, 70)
        win = curses.newwin(5, win_w, max(1, h // 2 - 2), max(1, (w - win_w) // 2))
        win.keypad(True)
        while True:
            win.erase()
            win.box()
            win.addnstr(0, 2, " %s " % title, win_w - 4, curses.A_BOLD)
            win.addnstr(2, 2, question[: win_w - 4], win_w - 4)
            win.addnstr(3, 2, "y: yes   n/Esc: no", win_w - 4, curses.A_DIM)
            win.refresh()
            key = win.getch()
            if key in (ord("y"), ord("Y")):
                return True
            if key in (ord("n"), ord("N"), 27):
                return False

    def pick(self, title, entries):
        if not entries:
            self.message = "Nothing to choose"
            return None
        h, w = self.size()
        win_h = min(len(entries) + 3, h - 5)
        win_w = min(w - 4, 70)
        win = curses.newwin(win_h, win_w, max(2, (h - win_h) // 2), max(1, (w - win_w) // 2))
        win.keypad(True)
        sel, off = 0, 0
        while True:
            win.erase()
            win.box()
            win.addnstr(0, 2, " %s " % title, win_w - 4, curses.A_BOLD)
            visible = max(1, win_h - 3)
            if sel < off:
                off = sel
            if sel >= off + visible:
                off = sel - visible + 1
            for i in range(visible):
                idx = off + i
                if idx >= len(entries):
                    break
                attr = (curses.color_pair(3) if self.color else curses.A_REVERSE) if idx == sel else curses.A_NORMAL
                win.addnstr(1 + i, 1, entries[idx][0].ljust(win_w - 2), win_w - 2, attr)
            win.addnstr(win_h - 2, 1, " Enter: choose   Esc: cancel ", win_w - 2, curses.A_DIM)
            win.refresh()
            key = win.getch()
            if key in (curses.KEY_UP, ord("k")):
                sel = max(0, sel - 1)
            elif key in (curses.KEY_DOWN, ord("j")):
                sel = min(len(entries) - 1, sel + 1)
            elif key in (10, 13, curses.KEY_ENTER):
                return entries[sel][1]
            elif key == 27:
                return None

    # ---------- actions ----------
    def action_add(self):
        src = self.input_box("Add torrent: URL, magnet or .torrent path")
        if not src:
            return
        try:
            self.rpc.call("load.start", "", src.strip())
            self.message = "Added"
        except Exception as exc:  # noqa: BLE001
            self.message = "Add failed: %s" % exc
        self.refresh()

    def action_start(self):
        row = self.selected()
        if not row:
            return
        try:
            self.rpc.call("d.start", row["hash"])
            self.message = "Started"
        except Exception as exc:  # noqa: BLE001
            self.message = "Start failed: %s" % exc
        self.refresh()

    def action_stop(self):
        row = self.selected()
        if not row:
            return
        try:
            self.rpc.call("d.stop", row["hash"])
            self.message = "Stopped"
        except Exception as exc:  # noqa: BLE001
            self.message = "Stop failed: %s" % exc
        self.refresh()

    def action_remove(self):
        row = self.selected()
        if not row:
            return
        choice = self.pick("Remove %s" % row["name"][:40], [
            ("Remove from list (keep files)", "keep"),
            ("Remove and delete data", "data"),
        ])
        if not choice:
            return
        directory = None
        try:
            directory = self.rpc.call("d.directory", row["hash"])
        except Exception:
            pass
        try:
            self.rpc.call("d.erase", row["hash"])
            if choice == "data" and directory:
                self.delete_path(directory)
            else:
                self.message = "Removed from list"
        except Exception as exc:  # noqa: BLE001
            self.message = "Remove failed: %s" % exc
        self.refresh()

    def delete_path(self, path):
        base = os.path.realpath(os.environ.get("RTORRENT_DOWNLOAD") or os.path.expanduser("~/downloads"))
        target = os.path.realpath(path)
        if target == base or not target.startswith(base + os.sep):
            self.message = "Refused to delete outside %s" % base
            return
        try:
            shutil.rmtree(target)
            self.message = "Removed with data"
        except Exception as exc:  # noqa: BLE001
            self.message = "Delete failed: %s" % exc

    def action_details(self):
        row = self.selected()
        if not row:
            return
        try:
            directory = self.rpc.call("d.directory", row["hash"])
        except Exception:
            directory = "?"
        lines = [
            ("Name", row["name"]),
            ("Hash", row["hash"]),
            ("Size", human_size(row["size"])),
            ("Downloaded", human_size(row["done"])),
            ("Ratio", "%.2f" % (row["ratio"] / 1000.0)),
            ("Down", human_rate(row["down"])),
            ("Up", human_rate(row["up"])),
            ("State", "active" if row["active"] else "stopped"),
            ("Directory", str(directory)),
        ]
        self.pick("Details", [(("%-10s %s" % (k, v))[:100], None) for k, v in lines])

    def action_limits(self):
        try:
            down = int(self.rpc.call("throttle.global_down.max_rate"))
            up = int(self.rpc.call("throttle.global_up.max_rate"))
        except Exception:
            down = up = 0
        d = self.input_box("Download limit in KB/s (0 = unlimited)", str(down // 1024))
        if d is None:
            return
        u = self.input_box("Upload limit in KB/s (0 = unlimited)", str(up // 1024))
        if u is None:
            return
        try:
            self.rpc.call("throttle.global_down.max_rate.set_kb", "", str(int(float(d))))
            self.rpc.call("throttle.global_up.max_rate.set_kb", "", str(int(float(u))))
            self.message = "Limits set"
        except Exception as exc:  # noqa: BLE001
            self.message = "Limits failed: %s" % exc

    def action(self, name):
        if name == "add":
            self.action_add()
        elif name == "start":
            self.action_start()
        elif name == "stop":
            self.action_stop()
        elif name == "remove":
            self.action_remove()
        elif name == "details":
            self.action_details()
        elif name == "limits":
            self.action_limits()
        elif name == "refresh":
            self.refresh()
            self.message = "Refreshed"
        elif name == "quit":
            raise SystemExit

    # ---------- input ----------
    def handle_key(self, key):
        if key == 9:
            self.focus = "buttons" if self.focus == "table" else "table"
            return
        if self.focus == "buttons":
            if key in (curses.KEY_LEFT, ord("h")):
                self.button = (self.button - 1) % len(self.buttons)
            elif key in (curses.KEY_RIGHT, ord("l")):
                self.button = (self.button + 1) % len(self.buttons)
            elif key in (10, 13, curses.KEY_ENTER):
                self.action(self.buttons[self.button][1])
            return
        if key in (curses.KEY_UP, ord("k")):
            self.sel = max(0, self.sel - 1)
        elif key in (curses.KEY_DOWN, ord("j")):
            self.sel = min(max(0, len(self.rows) - 1), self.sel + 1)
        elif key == curses.KEY_PPAGE:
            self.sel = max(0, self.sel - max(1, self.size()[0] - 5))
        elif key == curses.KEY_NPAGE:
            self.sel = min(max(0, len(self.rows) - 1), self.sel + max(1, self.size()[0] - 5))
        elif key == ord("a"):
            self.action_add()
        elif key == ord("s"):
            self.action_start()
        elif key == ord("S"):
            self.action_stop()
        elif key == ord("d"):
            self.action_remove()
        elif key in (10, 13, curses.KEY_ENTER, ord("o")):
            self.action_details()
        elif key == ord("l"):
            self.action_limits()
        elif key == ord("r"):
            self.refresh()
            self.message = "Refreshed"
        elif key == ord("?"):
            self.show_help()
        elif key == ord("q"):
            raise SystemExit

    def show_help(self):
        lines = [
            ("Tab", "switch between torrent list and buttons"),
            ("Up/Down, PgUp/PgDn", "select a torrent"),
            ("a", "add a URL, magnet link or .torrent file"),
            ("s / S", "start / stop the selected torrent"),
            ("d", "remove the selected torrent"),
            ("Enter or o", "details of the selected torrent"),
            ("l", "download and upload limits"),
            ("r", "refresh now"),
            ("q", "quit"),
        ]
        self.pick("Help", [(("%-20s %s" % (k, v)), None) for k, v in lines])

    def run(self):
        try:
            self.version = self.rpc.call("system.client_version")
        except Exception:
            self.version = "?"
        self.refresh()
        self.scr.timeout(2000)
        while True:
            self.redraw()
            key = self.scr.getch()
            if key == -1:
                self.refresh()
                continue
            if key == curses.KEY_RESIZE:
                continue
            if key == curses.KEY_MOUSE:
                try:
                    _, mx, my, _, bstate = curses.getmouse()
                except curses.error:
                    continue
                if bstate & curses.BUTTON1_CLICKED:
                    if my == 1:
                        idx = 0
                        for i, x in enumerate(self.button_xs):
                            if x <= mx:
                                idx = i
                        self.button = idx
                        self.focus = "buttons"
                        self.action(self.buttons[idx][1])
                    elif my >= 3 and self.rows:
                        clicked = self.scroll + (my - 3)
                        if 0 <= clicked < len(self.rows):
                            self.sel = clicked
                    self.redraw()
                    continue
                if bstate & curses.BUTTON4_PRESSED:
                    self.sel = max(0, self.sel - 3)
                elif bstate & curses.BUTTON5_PRESSED:
                    self.sel = min(max(0, len(self.rows) - 1), self.sel + 3)
                continue
            self.handle_key(key)


def main(stdscr):
    curses.curs_set(0)
    stdscr.keypad(True)
    try:
        curses.mousemask(curses.ALL_MOUSE_EVENTS)
    except curses.error:
        pass
    try:
        curses.start_color()
        curses.use_default_colors()
        curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
        curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_CYAN)
        curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_YELLOW)
    except curses.error:
        pass
    App(stdscr).run()


if __name__ == "__main__":
    if "--version" in sys.argv:
        print("termtorrent %s" % VERSION)
        raise SystemExit(0)
    if "-h" in sys.argv or "--help" in sys.argv:
        print("usage: termtorrent")
        print("An ncurses front-end for rtorrent, using its SCGI socket.")
        raise SystemExit(0)
    if os.environ.get("TERM", "") in ("", "dumb", "unknown"):
        os.environ["TERM"] = "xterm-256color"
    try:
        curses.wrapper(main)
    except Exception:
        import traceback
        with open("/tmp/termtorrent.err", "w", encoding="utf-8") as fh:
            traceback.print_exc(file=fh)
        raise
