#!/usr/bin/env python3
"""termbrowser - a small ncurses web browser with an address bar and toolbar.

Renders pages with lynx -dump and displays them in a curses interface.
No CSS/JS; supports: address bar, buttons, history, links, bookmarks.
"""

import curses
import json
import os
import shutil
import subprocess
import sys

VERSION = "0.1"
BOOKMARKS = os.path.expanduser("~/.termbrowser_bookmarks.json")
START_URL = "termbrowser:start"
START_HTML = r'''<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>termbrowser</title></head>
<body>
<h1>termbrowser</h1>
<p>A small web browser for the terminal, with the conveniences of a normal
browser: an address bar that is always visible, a toolbar, history, bookmarks
and a link list. Pages are rendered as text.</p>

<h2>The address bar</h2>
<p>The top line shows the address of the page you are reading.</p>
<ul>
<li><b>Tab</b> moves the focus to the address bar. The current address is
selected, so typing replaces it.</li>
<li><b>Enter</b> loads what you typed. Without http:// or https:// the browser
assumes https://.</li>
<li><b>Esc</b> leaves the address bar without loading anything.</li>
<li><b>Ctrl-U</b> clears the address bar.</li>
</ul>

<h2>The toolbar</h2>
<p>Use <b>Tab</b> to move the focus to the buttons, the arrow keys to pick one
and <b>Enter</b> to press it. Clicking also works where a mouse is available.</p>
<ul>
<li><b>Back</b> - go to the previous page in this session.</li>
<li><b>Forward</b> - go forward again after going back.</li>
<li><b>Reload</b> - fetch the current page again.</li>
<li><b>Home</b> - return to this start page.</li>
<li><b>Links</b> - list every link on the current page.</li>
<li><b>Bookmarks</b> - open your bookmarks, or add the current page.</li>
<li><b>Quit</b> - close the browser.</li>
</ul>

<h2>Keyboard shortcuts</h2>
<ul>
<li><b>Arrow keys, PgUp, PgDn</b> - scroll the page.</li>
<li><b>g</b> and <b>G</b> - jump to the top or the bottom.</li>
<li><b>b</b> and <b>f</b> - back and forward.</li>
<li><b>r</b> - reload.</li>
<li><b>l</b> - list the links on the page.</li>
<li><b>B</b> - bookmarks.</li>
<li><b>?</b> - help, with the same list inside the browser.</li>
<li><b>q</b> - quit.</li>
</ul>

<h2>Mouse</h2>
<p>Where the terminal supports it, you can click the toolbar buttons and the
address bar, and scroll with the wheel. On a plain Linux console this needs
gpm running.</p>

<h2>Bookmarks</h2>
<p>Press <b>B</b> to open the bookmark list. The last entry adds the page you
are on. Bookmarks are stored in <b>~/.termbrowser_bookmarks.json</b> and survive
restarts.</p>

<h2>Links on a page</h2>
<p>Press <b>l</b> for a list of every link on the page. Choose with the arrow
keys, open with <b>Enter</b>, close with <b>Esc</b>.</p>

<h2>Good to know</h2>
<ul>
<li>Text only: no CSS, no JavaScript, no images.</li>
<li>Pages that depend on JavaScript will not work.</li>
<li>Loading a page can take a few seconds, especially on old hardware.</li>
<li>This start page is built into the program and works without a network.</li>
<li>History is kept for the current session only.</li>
</ul>

<h2>Start here</h2>
<ul>
<li><a href="https://en.wikipedia.org/wiki/Main_Page">Wikipedia</a></li>
<li><a href="https://news.ycombinator.com/">Hacker News</a></li>
<li><a href="https://wttr.in/">Weather - wttr.in</a></li>
<li><a href="https://html.duckduckgo.com/html/">DuckDuckGo search</a></li>
<li><a href="https://www.iana.org/help/example-domains">IANA example domains</a></li>
</ul>

<p>Type any address in the bar above to go somewhere else.</p>
</body>
</html>'''
HOME_URL = START_URL
LYNX = shutil.which("lynx") or "/usr/bin/lynx"


def start_page_path():
    base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
    folder = os.path.join(base, "termbrowser")
    os.makedirs(folder, mode=0o700, exist_ok=True)
    path = os.path.join(folder, "start.html")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(START_HTML)
    return path


def normalize(url):
    url = url.strip()
    if not url:
        return None
    if url == START_URL:
        return url
    if "://" not in url:
        url = "https://" + url
    if not (url.startswith("http://") or url.startswith("https://")):
        return None
    return url


def load_bookmarks():
    try:
        with open(BOOKMARKS, encoding="utf-8") as f:
            data = json.load(f)
            if isinstance(data, list):
                return [d for d in data if isinstance(d, str)]
    except Exception:
        pass
    return []


def save_bookmarks(items):
    try:
        with open(BOOKMARKS, "w", encoding="utf-8") as f:
            json.dump(items, f, ensure_ascii=False, indent=1)
    except Exception:
        pass


def fetch(url, width):
    """Return (text_lines, links) where links maps number -> url."""
    width = max(40, width)
    if url == START_URL:
        url = "file://" + start_page_path()
    try:
        dump = subprocess.run(
            [LYNX, "-dump", "-width=%d" % width, url],
            capture_output=True, text=True, errors="replace", timeout=20,
        )
    except subprocess.TimeoutExpired:
        return ["Loading the page took too long."], {}
    except Exception as exc:  # noqa: BLE001
        return ["Could not load page: %s" % exc], {}

    text = dump.stdout or ""
    if dump.returncode != 0 and not text.strip():
        err = (dump.stderr or "").strip().splitlines()
        return ["Error loading: " + (err[-1] if err else "unknown error")], {}

    body = text
    refs = ""
    marker = "\nReferences\n"
    pos = text.rfind(marker)
    if pos != -1:
        body, refs = text[:pos], text[pos + len(marker):]

    lines = [ln.rstrip() for ln in body.expandtabs(4).splitlines()]
    while lines and not lines[-1].strip():
        lines.pop()
    links = {}
    for raw in refs.splitlines():
        raw = raw.strip()
        if not raw:
            continue
        num, _, rest = raw.partition(".")
        if num.isdigit() and rest.strip().startswith("http"):
            links[int(num)] = rest.strip()
    return lines, links


class Browser:
    def __init__(self, stdscr, url):
        self.scr = stdscr
        self.url = normalize(url) or HOME_URL
        self.history = [self.url]
        self.hindex = 0
        self.lines = []
        self.links = {}
        self.scroll = 0
        self.focus = "url"  # url | buttons | content
        self.button = 0
        self.message = ""
        self.bookmarks = load_bookmarks()
        self.color = curses.has_colors()
        self.url_sel = True
        self.button_xs = []
        self.buttons = [
            ("\u25c0 Back", "back"),
            ("\u25b6 Forward", "forward"),
            ("\u27f3 Reload", "reload"),
            ("\u2302 Home", "home"),
            ("Links", "links"),
            ("Bookmarks", "bookmarks"),
            ("Quit", "quit"),
        ]

    # ---------- helpers ----------
    def size(self):
        h, w = self.scr.getmaxyx()
        return h, w

    def body_height(self):
        h, _ = self.size()
        return max(1, h - 4)

    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_content(self):
        h, w = self.size()
        self.scr.move(2, 0)
        self.scr.clrtobot()
        lines = self.lines or [""]
        visible = self.body_height()
        for i in range(visible):
            idx = self.scroll + i
            if idx >= len(lines):
                break
            try:
                self.scr.addnstr(2 + i, 0, lines[idx], w - 1)
            except curses.error:
                pass

    def draw_status(self):
        h, w = self.size()
        total = len(self.lines)
        pos = min(total, self.scroll + self.body_height())
        mode = {"url": "address", "buttons": "buttons", "content": "page"}[self.focus]
        msg = self.message or "Tab: %s | ?: help | q: quit" % mode
        status = " %s  [%d-%d/%d] " % (msg, min(total, self.scroll + 1), pos, total)
        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):
        h, w = self.size()
        self.scr.erase()
        self.draw_bar(0, " URL  " + self.url, self.focus == "url", " termbrowser ")
        self.draw_buttons()
        self.draw_content()
        self.draw_status()
        try:
            if self.focus == "url":
                curses.curs_set(1)
                self.scr.move(0, min(len(" URL  " + self.url), max(0, w - 15)))
            else:
                curses.curs_set(0)
        except curses.error:
            pass
        self.scr.refresh()

    # ---------- navigation ----------
    def load(self, url, push=True):
        url = normalize(url)
        if not url:
            self.message = "Invalid URL"
            return
        self.url = url
        if push:
            self.history = self.history[: self.hindex + 1]
            self.history.append(url)
            self.hindex = len(self.history) - 1
        self.scroll = 0
        self.lines = ["Loading..."]
        self.links = {}
        self.redraw()
        h, w = self.size()
        self.lines, self.links = fetch(url, w - 2)
        self.scroll = 0
        self.focus = "content"
        self.url_sel = False

    def back(self):
        if self.hindex > 0:
            self.hindex -= 1
            self.url = self.history[self.hindex]
            self.scroll = 0
            self.lines, self.links = fetch(self.url, self.size()[1] - 2)
            self.focus = "content"
        else:
            self.message = "No history"

    def forward(self):
        if self.hindex < len(self.history) - 1:
            self.hindex += 1
            self.url = self.history[self.hindex]
            self.scroll = 0
            self.lines, self.links = fetch(self.url, self.size()[1] - 2)
            self.focus = "content"
        else:
            self.message = "No forward history"

    def activate_button(self):
        action = self.buttons[self.button][1]
        if action == "back":
            self.back()
        elif action == "forward":
            self.forward()
        elif action == "reload":
            self.load(self.url, push=False)
        elif action == "home":
            self.load(HOME_URL)
        elif action == "links":
            self.show_links()
        elif action == "bookmarks":
            self.show_bookmarks()
        elif action == "quit":
            raise SystemExit

    # ---------- overlays ----------
    def pick(self, title, entries):
        """entries: list of (label, value). Returns value or None."""
        if not entries:
            self.message = "Nothing to show"
            return None
        h, w = self.size()
        win_h = min(len(entries) + 3, h - 5)
        win_w = min(w - 4, 76)
        top = max(2, (h - win_h) // 2)
        left = max(1, (w - win_w) // 2)
        win = curses.newwin(win_h, win_w, top, left)
        win.keypad(True)
        sel = 0
        off = 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
                label = entries[idx][0][: win_w - 4]
                if idx == sel:
                    attr = curses.color_pair(3) if curses.has_colors() else curses.A_REVERSE
                else:
                    attr = curses.A_NORMAL
                win.addnstr(1 + i, 1, label.ljust(win_w - 2), win_w - 2, attr)
            hint = " Enter: open   Esc: close   arrows: choose "
            win.addnstr(win_h - 2, 1, hint[: win_w - 2].ljust(win_w - 2), win_w - 2, curses.A_DIM)
            win.refresh()
            key = win.getch()
            if key == curses.KEY_MOUSE:
                continue
            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 (curses.KEY_PPAGE,):
                sel = max(0, sel - visible)
            elif key in (curses.KEY_NPAGE,):
                sel = min(len(entries) - 1, sel + visible)
            elif key in (10, 13, curses.KEY_ENTER):
                return entries[sel][1]
            elif key in (27, ord("q")):
                return None

    def show_links(self):
        entries = []
        for num in sorted(self.links):
            url = self.links[num]
            entries.append(("[%d] %s" % (num, url), url))
        target = self.pick("Links on this page", entries)
        if target:
            self.load(target)

    def show_bookmarks(self):
        entries = [(b, b) for b in self.bookmarks]
        entries.append(("[ add current page ]", "__add__"))
        choice = self.pick("Bookmarks", entries)
        if choice == "__add__":
            if self.url not in self.bookmarks:
                self.bookmarks.append(self.url)
                save_bookmarks(self.bookmarks)
                self.message = "Bookmark added"
        elif choice:
            self.load(choice)

    def show_help(self):
        lines = [
            ("Tab", "switch between address bar, buttons and page"),
            ("Enter", "load URL / activate button / open link"),
            ("Arrows / PgUp / PgDn", "scroll"),
            ("b / f", "back / forward"),
            ("r", "reload"),
            ("l", "list links"),
            ("B", "bookmarks"),
            ("g / G", "top / bottom"),
            ("q", "quit"),
        ]
        self.pick("Help", [(k + "  -  " + v, None) for k, v in lines])

    # ---------- input ----------
    def handle_url_key(self, key):
        if key in (10, 13, curses.KEY_ENTER):
            self.load(self.url)
            self.url_sel = False
        elif key in (curses.KEY_BACKSPACE, 127, 8):
            if self.url_sel:
                self.url = ""
                self.url_sel = False
            else:
                self.url = self.url[:-1]
        elif key == 21:  # Ctrl-U
            self.url = ""
            self.url_sel = False
        elif key == 9:  # Tab
            self.focus = "buttons"
            self.url_sel = False
        elif key == 27:
            self.focus = "content"
            self.url_sel = False
        elif 32 <= key < 127:
            if self.url_sel:
                self.url = chr(key)
                self.url_sel = False
            else:
                self.url += chr(key)

    def handle_content_key(self, key):
        page = max(1, self.body_height() - 1)
        if key in (curses.KEY_UP, ord("k")):
            self.scroll = max(0, self.scroll - 1)
        elif key in (curses.KEY_DOWN, ord("j")):
            self.scroll = min(max(0, len(self.lines) - 1), self.scroll + 1)
        elif key == curses.KEY_PPAGE:
            self.scroll = max(0, self.scroll - page)
        elif key == curses.KEY_NPAGE:
            self.scroll = min(max(0, len(self.lines) - self.body_height()), self.scroll + page)
        elif key == ord("g"):
            self.scroll = 0
        elif key == ord("G"):
            self.scroll = max(0, len(self.lines) - self.body_height())
        elif key == 9:
            self.focus = "url"
            self.url_sel = True
        elif key in (ord("b"),):
            self.back()
        elif key in (ord("f"),):
            self.forward()
        elif key == ord("r"):
            self.load(self.url, push=False)
        elif key == ord("l"):
            self.show_links()
        elif key in (ord("B"),):
            self.show_bookmarks()
        elif key == ord("?"):
            self.show_help()
        elif key in (ord("q"),):
            raise SystemExit

    def handle_button_key(self, key):
        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.activate_button()
        elif key == 9:
            self.focus = "content"

    def run(self):
        self.load(self.url)
        while True:
            self.redraw()
            key = self.scr.getch()
            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 == 0:
                        self.focus = "url"
                        self.url_sel = True
                    elif my == 1:
                        idx = 0
                        for i, x in enumerate(self.button_xs):
                            if x <= mx:
                                idx = i
                        self.button = idx
                        self.focus = "buttons"
                        self.activate_button()
                    self.redraw()
                    continue
                if bstate & curses.BUTTON4_PRESSED:
                    self.scroll = max(0, self.scroll - 3)
                elif bstate & curses.BUTTON5_PRESSED:
                    self.scroll = min(max(0, len(self.lines) - self.body_height()), self.scroll + 3)
                continue
            if self.focus == "url":
                self.handle_url_key(key)
            elif self.focus == "buttons":
                self.handle_button_key(key)
            else:
                self.handle_content_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)
        curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLACK)
    except curses.error:
        pass
    url = sys.argv[1] if len(sys.argv) > 1 else HOME_URL
    Browser(stdscr, url).run()


if __name__ == "__main__":
    args = [a for a in sys.argv[1:] if not a.startswith("-")]
    if "--version" in sys.argv:
        print("termbrowser %s" % VERSION)
        raise SystemExit(0)
    if "-h" in sys.argv or "--help" in sys.argv:
        print("usage: termbrowser [URL]")
        print()
        print("A small ncurses web browser. Requires python3 and lynx.")
        print("Keys: Tab focus, Enter open, l links, B bookmarks, ? help, q quit.")
        raise SystemExit(0)
    if not args:
        sys.argv = [sys.argv[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/termbrowser.err", "w", encoding="utf-8") as fh:
            traceback.print_exc(file=fh)
        raise
