#!/usr/bin/env python3
"""Generate interactive-shell mini-help from the ngspice-jit HTML manual.

Source of truth: manual/ngspice_manual.html.  Each <div class="cmd"> section
carries a uniform  Synopsis / Description / See also  set of fields.  We pull
those three fields into ~15 lines of plain text, keyed by every command token
named in the section heading (plus its dot-stripped form), and emit a sorted
C lookup table consumed by com_help().

Usage:
    gen_minihelp.py MANUAL.html --text        # human-readable dump (review)
    gen_minihelp.py MANUAL.html --c OUT.c      # generated C table
"""
import sys, re, html, argparse

# --- tiny HTML helpers -------------------------------------------------------

def strip_tags(s):
    """Flatten inline markup to text; <a href=#x><code>y</code></a> -> y."""
    s = re.sub(r'<[^>]+>', '', s)
    s = html.unescape(s)
    return re.sub(r'[ \t]+', ' ', s).strip()

def wrap(text, width=76, indent=""):
    out, line = [], indent
    for word in text.split():
        if line != indent and len(line) + 1 + len(word) > width:
            out.append(line)
            line = indent + word
        else:
            line = (line + " " + word) if line != indent else indent + word
    if line.strip():
        out.append(line)
    return out

# --- section parsing ---------------------------------------------------------

CMD_SPLIT = re.compile(r'<div class="cmd">')
H3 = re.compile(r'<h3 id="([^"]+)">(.*?)</h3>', re.S)

def find_field(body, label):
    """Return inner HTML of the <div class="field"> whose <b> title == label."""
    # match: <div class="field"><b>LABEL</b> ...balanced-ish... </div>
    # fields don't nest <div>, so grab up to the next <div or end.
    pat = re.compile(r'<div class="field"><b>' + re.escape(label) +
                     r'</b>(.*?)</div>', re.S)
    m = pat.search(body)
    return m.group(1) if m else None

def parse_heading(h3_inner):
    """-> (names[list], tagline, mode) from the h3 inner html."""
    mode = ""
    mm = re.search(r'<span class="mode[^"]*">([^<]*)</span>', h3_inner)
    if mm:
        mode = mm.group(1).strip()
    head = re.sub(r'<span class="mode[^"]*">[^<]*</span>', '', h3_inner)
    # split on em-dash: left = command tokens, right = tagline
    parts = re.split(r'\s*[—-]\s*', strip_tags(head), maxsplit=1)
    # names come from <code>..</code> tokens on the left of the dash
    left_html = head.split('—')[0] if '—' in head else head
    names = re.findall(r'<code>([^<]+)</code>', left_html)
    names = [html.unescape(n).strip() for n in names]
    tagline = parts[1].strip() if len(parts) > 1 else ""
    return names, tagline, mode

def synopsis_text(field_html):
    m = re.search(r'<pre[^>]*>(.*?)</pre>', field_html, re.S)
    if not m:
        return []
    raw = html.unescape(re.sub(r'<[^>]+>', '', m.group(1)))
    return [ln.rstrip() for ln in raw.strip("\n").splitlines()]

def description_text(field_html):
    # first paragraph(s) of the Description field only
    paras = re.findall(r'<p>(.*?)</p>', field_html, re.S)
    if not paras:
        # description sometimes sits directly after <b>Description</b>
        paras = [field_html]
    return [strip_tags(p) for p in paras if strip_tags(p)]

def table_option_keys(field_html):
    """Option names in the first column of the Description table become lookup
    keys, so `help floatgmin` / `help pstran` land on the .option entry that
    lists them.  First cells look like 'reltol' or 'pstran / dptran' or
    'abstol / vntol' -> split into individual typeable tokens."""
    m = re.search(r'<table[^>]*>(.*?)</table>', field_html, re.S)
    if not m:
        return []
    out = []
    for row in re.findall(r'<tr[^>]*>(.*?)</tr>', m.group(1), re.S):
        if '<th' in row:
            continue
        cell = re.search(r'<td[^>]*>(.*?)</td>', row, re.S)
        if not cell:
            continue
        for tok in re.split(r'[^\w.$@-]+', strip_tags(cell.group(1))):
            tok = tok.strip().lower()
            if tok and re.match(r'^[.@a-z][\w.$@-]*$', tok):
                out.append(tok)
    return out

def description_table(field_html):
    """Render EVERY <table> in the Description field as compact
    'name [default] - effect' lines.  This is how the .option help lists
    every option (reltol, floatgmin, pstran, ...) — the rows are the content,
    and pulling only the <p> intro would drop them entirely.

    All tables, not just the first: a section that documents two different
    things in tables (hier_op has one for the rung ladder and one for its
    command-line options) otherwise loses the second one silently, so the
    manual lists an option and `help` does not."""
    out = []
    for tbl in re.findall(r'<table[^>]*>(.*?)</table>', field_html, re.S):
        if out:
            out.append("")               # blank line between tables
        for row in re.findall(r'<tr[^>]*>(.*?)</tr>', tbl, re.S):
            if '<th' in row:                 # skip the header row
                continue
            cells = [strip_tags(c) for c in re.findall(r'<td[^>]*>(.*?)</td>', row, re.S)]
            if len(cells) >= 3:
                name, dflt, effect = cells[0], cells[1], " ".join(cells[2:])
                out.append("  %s [%s] - %s" % (name, dflt, effect))
            elif len(cells) == 2:
                out.append("  %s - %s" % (cells[0], cells[1]))
    return out

# --- key normalisation -------------------------------------------------------

def keys_for(names):
    """Command tokens -> lookup keys (lowercased, plus dot-stripped variant)."""
    keys = []
    for n in names:
        n = n.strip()
        # skip pure syntax tokens that aren't typeable command names
        if not re.match(r'^[.@A-Za-z][\w.$&-]*$', n):
            continue
        k = n.lower()
        keys.append(k)
        if k.startswith('.'):
            keys.append(k[1:])           # help tran -> .tran
    # de-dup, keep order
    seen, out = set(), []
    for k in keys:
        if k not in seen:
            seen.add(k); out.append(k)
    return out

# --- build one entry ---------------------------------------------------------

def build_entries(htmltext):
    entries = []           # (primary_name, names, mode, text)
    for chunk in CMD_SPLIT.split(htmltext)[1:]:
        m = H3.search(chunk)
        if not m:
            continue
        sec_id, h3_inner = m.group(1), m.group(2)
        body = chunk[m.end():]
        names, tagline, mode = parse_heading(h3_inner)
        if not names:
            continue
        keys = keys_for(names)
        for n in names:                       # @device[param] accessor
            if n.startswith('@') and '@' not in keys:
                keys.append('@')
        if not keys:                          # overview sections w/o a command
            keys = [sec_id]

        syn = find_field(body, "Synopsis")
        desc = find_field(body, "Description")
        see = find_field(body, "See also")

        # option names in the Description table are SECONDARY keys
        # (help floatgmin / help pstran -> this entry).  They are claimed only
        # after every primary (heading) key, so a real command section always
        # wins -- e.g. `help temp` stays the .temp command, not .option.
        skeys = []
        if desc:
            for k in table_option_keys(desc):
                if k not in keys and k not in skeys:
                    skeys.append(k)

        lines = []
        title = names[0]
        header = title + (("  —  " + tagline) if tagline else "")
        if mode:
            header += "   [" + mode + "]"
        lines.append(header)
        if len(names) > 1:
            lines.append("also: " + ", ".join(names[1:]))
        lines.append("")
        if syn:
            lines.append("Synopsis:")
            for ln in synopsis_text(syn):
                lines.append("  " + ln)
            lines.append("")
        if desc:
            for p in description_text(desc):
                lines += wrap(p, 76)
                lines.append("")
            tbl = description_table(desc)
            if tbl:
                for t in tbl:
                    w = wrap(t, 76, indent="  ")
                    if not w:                          # separator between tables
                        lines.append("")
                        continue
                    lines.append(w[0])                 # row at indent 2
                    lines += ["    " + ln.strip() for ln in w[1:]]  # hang cont. at 4
                lines.append("")
        if see:
            seetxt = strip_tags(see)
            seetxt = re.sub(r'^See also\s*', '', seetxt)
            lines += wrap("See also: " + seetxt, 76)
        text = "\n".join(lines).rstrip() + "\n"
        entries.append((sec_id, keys, skeys, mode, text))
    return entries


# --- .option entries harvested from the C sources -----------------------------
# The manual documents .options as rows of one big table, so only the names some
# editor happened to list are reachable (`help pstran` works, `help pstrancp`
# does not).  The solver already carries the authoritative list: cktsopt.c has
# every option with its type and a one-line description, and cktntask.c holds the
# defaults.  Harvesting both means every option is reachable, with its real
# default, and it cannot drift from the code.

def parse_code_options(cktsopt, cktntask):
    """-> [{name, type, desc, default, note}] for every option in the C table."""
    src = open(cktsopt, errors="replace").read()

    # 1. the option table:  { "name", OPT_ID, IF_SET|IF_REAL, "description" }
    opts = {}
    for m in re.finditer(r'\{\s*"([^"]+)"\s*,\s*(OPT_\w+)\s*,\s*([^,]+),\s*"([^"]*)"',
                         src, re.S):
        name, oid, flags, desc = m.groups()
        t = ("flag" if "IF_FLAG" in flags else
             "real" if "IF_REAL" in flags else
             "integer" if "IF_INTEGER" in flags else
             "string" if "IF_STRING" in flags else "")
        opts[oid] = {"name": name, "type": t,
                     "desc": " ".join(desc.split()), "default": None, "note": ""}

    # 2. case OPT_ID: task->TSKfield = ...   -> which task field each option sets
    field_of = {}
    for m in re.finditer(r'case\s+(OPT_\w+)\s*:\s*task->(TSK\w+)', src):
        field_of.setdefault(m.group(1), m.group(2))

    # 3. tsk->TSKfield = <default>;  [/* note */]   in cktntask.c
    dsrc = open(cktntask, errors="replace").read()
    dflt = {}
    for m in re.finditer(r'tsk->(TSK\w+)\s*=\s*([^;]+);[ \t]*(?:/\*(.*?)\*/)?', dsrc):
        f, v, note = m.group(1), m.group(2).strip(), (m.group(3) or "").strip()
        # cktntask.c assigns each field twice: once copying an existing task
        # (tsk->X = def->X) and once with the literal default.  Only the literal
        # is the documented default.
        if "->" in v:
            continue
        dflt.setdefault(f, (v, " ".join(note.split())))

    out = []
    for oid, o in opts.items():
        f = field_of.get(oid)
        if f and f in dflt:
            o["default"], o["note"] = dflt[f]
        out.append(o)
    return sorted(out, key=lambda o: o["name"])


def build_option_entries(opts):
    """One entry per option, keyed ONLY as a secondary name so a manual section
    always wins (`help temp` stays .temp; `help rshunt` stays the .option page)."""
    entries = []
    for o in opts:
        head = o["name"] + "  \u2014  simulator option"
        bits = [b for b in (o["type"],) if b]
        if o["default"] is not None:
            d = o["default"]
            if o["type"] == "flag":
                d += " (off)" if d.strip() in ("0", "FALSE") else " (on)"
            bits.append("default " + d)
        if bits:
            head += " (" + ", ".join(bits) + ")"
        lines = [head + "   [batch + ui]", ""]
        lines.append("Synopsis:")
        lines.append("  .option " + o["name"] +
                     ("" if o["type"] == "flag" else "=<value>"))
        lines.append("")
        if o["desc"]:
            lines += wrap(o["desc"] + ".", 76)
            lines.append("")
        if o["note"]:
            lines += wrap("Note: " + o["note"], 76)
            lines.append("")
        lines.append("See also: .option")
        text = "\n".join(lines).rstrip() + "\n"
        entries.append(("opt-" + o["name"], [], [o["name"]], "batch + ui", text))
    return entries

# --- emitters ----------------------------------------------------------------

def emit_text(entries):
    for sec_id, keys, skeys, mode, text in entries:
        print("=" * 78)
        print("keys: " + " ".join(keys) + ("   (+opt: " + " ".join(skeys) + ")" if skeys else ""))
        print("-" * 78)
        print(text)

def c_escape(s):
    out = []
    for ln in s.split("\n"):
        out.append('    "' + ln.replace('\\', '\\\\').replace('"', '\\"') + '\\n"')
    return "\n".join(out)

def emit_c(entries):
    # flatten to (key -> text-index); one text blob per section, many keys
    print("/* Generated by docgen/gen_minihelp.py from manual/ngspice_manual.html.")
    print("   DO NOT EDIT.  Regenerate instead:")
    print("     python3 docgen/gen_minihelp.py manual/ngspice_manual.html \\")
    print("             --c src/frontend/minihelp_gen.c")
    print("   Provides:  const char *minihelp_lookup(const char *cmd);")
    print("   Returns the mini-help text for a command name (case-insensitive),")
    print("   or NULL if there is none. */")
    print("#include <stdlib.h>")
    print("#include <string.h>")
    print("#include <strings.h>")
    print()
    print("const char *minihelp_lookup(const char *cmd);")
    print()
    print("struct minihelp_ent { const char *key; const char *text; };")
    print()
    rows = []
    owner = {}                       # key -> (vname, sec_id) that claimed it first
    vnames = []
    for i, (sec_id, keys, skeys, mode, text) in enumerate(entries):
        vname = "mh_%d" % i
        vnames.append(vname)
        print("static const char " + vname + "[] =")
        print(c_escape(text) + ";")
        print()

    # Pass 1: primary (heading) keys, document order -- real commands win.
    # Pass 2: secondary (option-name) keys, only for keys no command claimed,
    # so `help temp` stays the .temp command while `help floatgmin` reaches
    # the .option entry.
    def claim(k, vname, sec_id, secondary):
        if k in owner:
            if not secondary:        # only warn on real command-name collisions
                sys.stderr.write(
                    "  collision: key '%s' claimed by '%s', "
                    "already owned by '%s' -> keeping first\n"
                    % (k, sec_id, owner[k][1]))
            return
        owner[k] = (vname, sec_id)
        rows.append((k, vname))
    for i, (sec_id, keys, skeys, mode, text) in enumerate(entries):
        for k in keys:
            claim(k, vnames[i], sec_id, False)
    for i, (sec_id, keys, skeys, mode, text) in enumerate(entries):
        for k in skeys:
            claim(k, vnames[i], sec_id, True)
    rows.sort(key=lambda r: r[0])
    print("/* keys sorted for bsearch() */")
    print("static const struct minihelp_ent minihelp_tab[] = {")
    for k, v in rows:
        print('    { "%s", %s },' % (k, v))
    print("};")
    print("static const int minihelp_ntab = %d;" % len(rows))
    print("""
static int mh_cmp(const void *key, const void *ent)
{
    return strcasecmp((const char *) key,
                      ((const struct minihelp_ent *) ent)->key);
}

const char *minihelp_lookup(const char *cmd)
{
    const struct minihelp_ent *e;
    if (!cmd || !*cmd)
        return (const char *) 0;
    e = (const struct minihelp_ent *) bsearch(cmd, minihelp_tab,
            (size_t) minihelp_ntab, sizeof(minihelp_tab[0]), mh_cmp);
    return e ? e->text : (const char *) 0;
}""")

# --- main --------------------------------------------------------------------

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("manual")
    ap.add_argument("--text", action="store_true")
    ap.add_argument("--c", metavar="OUT")
    ap.add_argument("--cktsopt", help="src/spicelib/analysis/cktsopt.c (option table)")
    ap.add_argument("--cktntask", help="src/spicelib/analysis/cktntask.c (defaults)")
    args = ap.parse_args()
    with open(args.manual, encoding="utf-8") as f:
        htmltext = f.read()
    entries = build_entries(htmltext)
    if args.cktsopt and args.cktntask:
        opts = parse_code_options(args.cktsopt, args.cktntask)
        entries += build_option_entries(opts)
        sys.stderr.write("harvested %d .options from the C sources\n" % len(opts))
    if args.c:
        import io
        buf = io.StringIO()
        old = sys.stdout; sys.stdout = buf
        emit_c(entries)
        sys.stdout = old
        with open(args.c, "w") as f:
            f.write(buf.getvalue())
        sys.stderr.write("wrote %s: %d sections\n" % (args.c, len(entries)))
    else:
        emit_text(entries)
        sys.stderr.write("\n%d sections extracted\n" % len(entries))

if __name__ == "__main__":
    main()
