# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2016, 2026 InterGenJLU
"""Inline markdown doc viewer — shared across Forge installer screens.

Originally lived in user.py for the MOK first-boot walkthrough; pulled
out 2026-05-26 when PackagesPage needed the same viewer for its
InterGen "CLICK HERE" link. Any future screen that wants to open a
user-facing markdown doc in an `Adw.Dialog` should import from here
rather than copying the dialog code.

API:
    find_doc(filename)              -> path or None
    markdown_to_pango(text)         -> Pango markup string
    show_doc_dialog(window, title, pango_body, source_path)
        -> opens the modal viewer
    show_missing_doc_dialog(window, doc_label, github_url)
        -> opens the fallback "doc not installed" dialog
    open_doc_by_filename(window, filename, title=None, github_url=None)
        -> one-call helper: find + read + render, with fallback
"""

import os
import re

from gi.repository import Adw, GLib, Gtk


# Paths the viewer tries, in order, to locate a shipped doc.
# Production: /usr/share/doc/intergenos/users/ (set by Forge package's
# do_install — see packages/desktop/forge/build.sh). Live-ISO read-only
# squashfs path. Dev fallbacks for source-tree runs.
DOC_SEARCH_PATHS = (
    "/usr/share/doc/intergenos/users",
    "/usr/local/share/doc/intergenos/users",
    "/run/squashfs/usr/share/doc/intergenos/users",
    os.path.expanduser("~/intergenos/docs/users"),
    "/mnt/intergenos/docs/users",
)


def find_doc(filename: str) -> str | None:
    """Return absolute path of the first DOC_SEARCH_PATHS entry that
    contains `filename`, or None."""
    for d in DOC_SEARCH_PATHS:
        candidate = os.path.join(d, filename)
        if os.path.exists(candidate):
            return candidate
    return None


def markdown_to_pango(md: str) -> str:
    """Tiny markdown → Pango markup converter for the inline viewer.

    Handles the subset our docs actually use: ATX headings (# / ## /
    ###), fenced code blocks (```), inline code (`...`), bold (**...**),
    italic (*...*), bullet lists (`- `). Everything else passes through
    as plain (escaped) text. No HTML/embeds, no Markdown tables, no
    autolinks. Sufficient for the operator-facing docs in docs/users/
    without pulling in a markdown library.

    Image syntax (`![alt](path)`) is NOT yet rendered — text-only
    output. Image-rendering would require switching the dialog body
    from a single Gtk.Label to a Gtk.Box of mixed Labels + Pictures
    (markdown-to-Pango becomes markdown-to-sequence-of-widgets)."""
    out: list[str] = []
    in_code = False
    for raw_line in md.splitlines():
        stripped = raw_line.strip()
        # Fenced code blocks toggle
        if stripped.startswith("```"):
            in_code = not in_code
            continue
        if in_code:
            out.append(f"<tt>{GLib.markup_escape_text(raw_line)}</tt>")
            continue
        # Horizontal rules → blank line
        if stripped in ("---", "***", "___"):
            out.append("")
            continue
        # ATX headings (deepest first so ### doesn't match # / ##)
        if raw_line.startswith("### "):
            text = GLib.markup_escape_text(raw_line[4:])
            out.append(f"<span size='large' weight='bold'>{text}</span>")
            continue
        if raw_line.startswith("## "):
            text = GLib.markup_escape_text(raw_line[3:])
            out.append(f"<span size='x-large' weight='bold'>{text}</span>")
            continue
        if raw_line.startswith("# "):
            text = GLib.markup_escape_text(raw_line[2:])
            out.append(f"<span size='xx-large' weight='bold'>{text}</span>")
            continue
        # Bullet list rewriting (top-level + 1 nested level)
        line = raw_line
        if re.match(r"^- ", line):
            line = "  • " + line[2:]
        elif re.match(r"^  - ", line):
            line = "    ◦ " + line[4:]
        # Escape, then inject Pango markup for the inline patterns.
        # The markdown delimiters (*, `) aren't HTML-special so escape-
        # first is safe.
        escaped = GLib.markup_escape_text(line)
        escaped = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", escaped)
        escaped = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<i>\1</i>", escaped)
        escaped = re.sub(r"`([^`]+)`", r"<tt>\1</tt>", escaped)
        out.append(escaped)
    return "\n".join(out)


def show_doc_dialog(window, title: str, pango_body: str,
                    source_path: str) -> None:
    """Open an Adw.Dialog rendering the markdown body as Pango.

    Opens scrolled to TOP with NOTHING selected and focus on Close.
    A selectable Gtk.Label that grabs focus auto-selects all its text,
    which then drags the ScrolledWindow's vadjustment to the caret
    (end of doc). Worked around by (a) marking the body label
    non-focusable so it can't be the initial focus target, and (b)
    using GLib.idle_add to reset scroll + clear selection + move focus
    to Close once layout completes."""
    dialog = Adw.Dialog()
    dialog.set_title(title)
    dialog.set_content_width(720)
    dialog.set_content_height(560)

    toolbar = Adw.ToolbarView()
    header = Adw.HeaderBar()
    header.add_css_class("forge-header")
    toolbar.add_top_bar(header)

    body = Gtk.Label()
    body.set_use_markup(True)
    body.set_markup(pango_body)
    body.set_selectable(True)
    body.set_wrap(True)
    body.set_xalign(0.0)
    body.set_yalign(0.0)
    body.set_margin_top(20)
    body.set_margin_bottom(20)
    body.set_margin_start(24)
    body.set_margin_end(24)
    body.add_css_class("forge-doc-body")
    # Selectable Gtk.Labels grab focus by default; combined with
    # set_selectable that lands the caret at the END of the label and
    # pulls ScrolledWindow's vadjustment all the way down. Marking
    # the label non-focusable prevents the initial-focus path while
    # still allowing click + drag selection once the dialog is open.
    body.set_can_focus(False)

    scroller = Gtk.ScrolledWindow()
    scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
    scroller.set_vexpand(True)
    scroller.set_child(body)
    toolbar.set_content(scroller)

    footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
    footer.set_halign(Gtk.Align.END)
    footer.set_margin_top(8)
    footer.set_margin_bottom(12)
    footer.set_margin_end(16)
    path_label = Gtk.Label(label=f"Source: {source_path}")
    path_label.add_css_class("dim-label")
    path_label.set_selectable(True)
    path_label.set_can_focus(False)
    path_label.set_halign(Gtk.Align.START)
    path_label.set_hexpand(True)
    path_label.set_margin_start(16)
    footer.append(path_label)
    close = Gtk.Button(label="Close")
    close.add_css_class("forge-nav-button")
    close.connect("clicked", lambda _b: dialog.close())
    footer.append(close)
    toolbar.add_bottom_bar(footer)

    dialog.set_child(toolbar)
    dialog.present(window)

    # Wait until after layout completes so vadjustment knows its real
    # upper bound. Reset scroll to top, clear any selection that may
    # have landed on the body, ensure focus sits on Close (standard
    # dialog default-action target).
    def _after_present():
        scroller.get_vadjustment().set_value(0.0)
        body.select_region(0, 0)
        close.grab_focus()
        return False  # one-shot idle callback

    GLib.idle_add(_after_present)


def show_missing_doc_dialog(window, doc_label: str, github_url: str) -> None:
    """Fallback dialog when find_doc() returned None — typically older
    ISOs that don't ship docs on-disk yet."""
    dialog = Adw.AlertDialog.new(
        f"{doc_label} doc not installed in this Forge build",
        (
            f"This live ISO doesn't ship the {doc_label} doc on-disk "
            "yet (older ISOs pre-2026-05-26). You can read it from "
            "another device at:\n\n" + github_url
        ),
    )
    dialog.add_response("ok", "OK")
    dialog.set_default_response("ok")
    dialog.set_close_response("ok")
    dialog.present(window)


def open_doc_by_filename(window, filename: str, title: str | None = None,
                        github_url: str | None = None,
                        doc_label: str | None = None) -> None:
    """One-call helper: find_doc + read + show_doc_dialog with a
    show_missing_doc_dialog fallback. Use this from screen handlers
    when you just want "open this doc in the viewer"."""
    path = find_doc(filename)
    if path is None:
        if github_url is None:
            github_url = (
                "https://github.com/InterGenJLU/intergenos/blob/master/"
                f"docs/users/{filename}"
            )
        show_missing_doc_dialog(
            window, doc_label or filename, github_url,
        )
        return
    try:
        with open(path, "r", encoding="utf-8") as f:
            markdown = f.read()
    except OSError:
        show_missing_doc_dialog(
            window, doc_label or filename,
            github_url or f"file://{path}",
        )
        return
    show_doc_dialog(
        window,
        title=title or filename,
        pango_body=markdown_to_pango(markdown),
        source_path=path,
    )
