#!/usr/bin/env python3
"""
Build the Fable knowledge base: ~/.claude/fable/fable.db (SQLite FTS5).

Indexes ONLY Fable-authored material from ~/.claude/fable/:
  - corpus/<project>/*.md   chunked by heading  -> kind='doc'
  - narration/excerpts.md   one row per excerpt -> kind='narration'

BM25-ranked full-text search. Zero dependencies (Python stdlib).
Re-runnable: drops and rebuilds the table each time.

Usage:
  python3 build_kb.py            # build from default ~/.claude/fable
  python3 build_kb.py --root DIR # build from a different corpus root
"""
import argparse, os, re, sqlite3, sys

HEAD = re.compile(r'^(#{1,6})\s+(.*)$')
MAXCHARS = 1400  # window long sections so a hit returns a focused passage


def sections(md):
    """Split markdown into (heading, body) sections by the nearest heading."""
    out, heading, buf = [], '(top)', []
    for line in md.splitlines():
        m = HEAD.match(line)
        if m:
            if buf:
                out.append((heading, '\n'.join(buf).strip()))
            heading, buf = m.group(2).strip(), []
        else:
            buf.append(line)
    if buf:
        out.append((heading, '\n'.join(buf).strip()))
    return [(h, b) for h, b in out if b]


def windows(body):
    """Split a long body into <=MAXCHARS windows on paragraph boundaries."""
    if len(body) <= MAXCHARS:
        return [body]
    chunks, cur = [], ''
    for para in body.split('\n\n'):
        if cur and len(cur) + len(para) > MAXCHARS:
            chunks.append(cur.strip())
            cur = ''
        cur += para + '\n\n'
    if cur.strip():
        chunks.append(cur.strip())
    return chunks


def load_docs(root):
    corpus = os.path.join(root, 'corpus')
    rows = []
    for dirpath, _, files in os.walk(corpus):
        for fn in sorted(files):
            if not fn.endswith('.md'):
                continue
            full = os.path.join(dirpath, fn)
            proj = os.path.basename(dirpath)
            src = os.path.relpath(full, root)
            try:
                md = open(full, encoding='utf-8', errors='ignore').read()
            except OSError:
                continue
            for heading, body in sections(md):
                for w in windows(body):
                    rows.append(('doc', proj, src, heading, w))
    return rows


def load_narration(root):
    path = os.path.join(root, 'narration', 'excerpts.md')
    rows = []
    if not os.path.exists(path):
        return rows
    cat = '(narration)'
    for line in open(path, encoding='utf-8', errors='ignore'):
        h = HEAD.match(line)
        if h:
            cat = h.group(2).split('—')[0].strip()
            continue
        if line.startswith('- '):
            m = re.match(r'-\s+(.*?)\s*`\[(.*?)\]`\s*$', line.strip())
            if m:
                rows.append(('narration', m.group(2), 'narration/excerpts.md', cat, m.group(1).strip()))
            else:
                rows.append(('narration', '?', 'narration/excerpts.md', cat, line[2:].strip()))
    return rows


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--root', default=os.path.expanduser('~/.claude/fable'))
    args = ap.parse_args()
    root = os.path.abspath(os.path.expanduser(args.root))
    if not os.path.isdir(os.path.join(root, 'corpus')):
        sys.exit(f'no corpus/ under {root} — run build-corpus first')

    rows = load_docs(root) + load_narration(root)
    db = os.path.join(root, 'fable.db')
    con = sqlite3.connect(db)
    con.execute('DROP TABLE IF EXISTS chunks')
    con.execute(
        "CREATE VIRTUAL TABLE chunks USING fts5("
        "text, kind UNINDEXED, project UNINDEXED, source UNINDEXED, heading UNINDEXED,"
        "tokenize='porter unicode61')"
    )
    con.executemany(
        'INSERT INTO chunks(kind,project,source,heading,text) VALUES (?,?,?,?,?)',
        [(k, p, s, h, t) for (k, p, s, h, t) in rows],
    )
    con.commit()
    n_doc = sum(1 for r in rows if r[0] == 'doc')
    n_narr = sum(1 for r in rows if r[0] == 'narration')
    print(f'built {db}: {n_doc} doc chunks + {n_narr} narration rows = {len(rows)} total')
    con.close()


if __name__ == '__main__':
    main()
