#!/usr/bin/env python3
"""visualize.py — render an OKF bundle as a self-contained interactive HTML page.

A reference OKF *consumer*: reads a bundle, builds a concept graph (nodes = the
concepts, edges = internal cross-links), and emits a single self-contained
`viz.html` that embeds the bundle as JSON. It renders a force-directed graph
(Cytoscape.js) with a detail panel that shows each concept's frontmatter and
rendered markdown body (marked.js). Both libraries load from a CDN; no data
leaves the page and there is no backend.

Usage:
    python visualize.py --bundle <bundle_dir> [--out <bundle>/viz.html] [--name "Title"]
"""

from __future__ import annotations

import argparse
import json
import os
import sys

from okf_common import (
    extract_links,
    is_agent_doc,
    is_internal_link,
    is_reserved,
    iter_markdown,
    read_document,
)


def _resolve(source_rel: str, target: str) -> str | None:
    target = target.split("#", 1)[0]
    if not target:
        return None
    if target.endswith("/"):
        target += "index.md"
    if target.startswith("/"):
        return target.lstrip("/")
    base_dir = os.path.dirname(source_rel)
    return os.path.normpath(os.path.join(base_dir, target)).replace(os.sep, "/")


def collect(bundle_root: str) -> dict:
    concepts: dict[str, dict] = {}
    for abs_path, rel in iter_markdown(bundle_root):
        if is_reserved(rel) or is_agent_doc(rel):
            continue
        try:
            fm, body, _raw = read_document(abs_path)
        except (OSError, UnicodeDecodeError):
            continue
        cid = rel[:-3]
        fm = fm or {}
        concepts[rel] = {
            "id": cid,
            "rel": rel,
            "type": str(fm.get("type", "Concept")) or "Concept",
            "title": str(fm.get("title", "")) or cid.split("/")[-1],
            "description": str(fm.get("description", "")),
            "tags": fm.get("tags", []) if isinstance(fm.get("tags", []), list) else [],
            "resource": str(fm.get("resource", "")),
            "body": body,
        }

    edges: list[dict] = []
    for rel, data in concepts.items():
        for _text, target in extract_links(data["body"]):
            if not is_internal_link(target):
                continue
            resolved = _resolve(rel, target)
            if resolved and resolved in concepts:
                edges.append({"source": rel, "target": resolved})

    return {"concepts": list(concepts.values()), "edges": edges}


HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>__NAME__ — OKF Bundle</title>
<script src="https://unpkg.com/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@13.0.0/marked.min.js"></script>
<style>
  :root { --bg:#0f1419; --panel:#1a2029; --fg:#e6edf3; --muted:#8b98a5; --accent:#4f9cf9; --border:#2a323d; }
  * { box-sizing:border-box; }
  body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--fg); }
  header { padding:12px 16px; border-bottom:1px solid var(--border); display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
  header h1 { font-size:16px; margin:0; font-weight:600; }
  header .meta { color:var(--muted); font-size:13px; }
  input, select { background:var(--panel); color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; font-size:13px; }
  #main { display:flex; height:calc(100vh - 53px); }
  #cy { flex:1; min-width:0; }
  #panel { width:420px; max-width:45vw; border-left:1px solid var(--border); overflow:auto; padding:18px; background:var(--panel); }
  #panel h2 { margin-top:0; font-size:18px; }
  .pill { display:inline-block; background:#243042; color:var(--accent); border-radius:999px; padding:2px 10px; font-size:12px; margin:2px 4px 2px 0; }
  .kv { color:var(--muted); font-size:13px; margin:4px 0; }
  .body { margin-top:14px; line-height:1.6; font-size:14px; }
  .body a { color:var(--accent); }
  .body table { border-collapse:collapse; width:100%; }
  .body th, .body td { border:1px solid var(--border); padding:6px 8px; text-align:left; }
  .body code { background:#0b0f14; padding:1px 5px; border-radius:4px; }
  .body pre { background:#0b0f14; padding:12px; border-radius:8px; overflow:auto; }
  .empty { color:var(--muted); padding:40px; text-align:center; }
  .backlinks { margin-top:18px; border-top:1px solid var(--border); padding-top:12px; }
  .backlinks a { display:block; color:var(--accent); font-size:13px; margin:3px 0; cursor:pointer; }
</style>
</head>
<body>
<header>
  <h1>__NAME__</h1>
  <span class="meta" id="counts"></span>
  <input id="search" placeholder="Search title, id, tags…" style="margin-left:auto;width:240px"/>
  <select id="typeFilter"><option value="">All types</option></select>
  <select id="layout">
    <option value="cose">cose</option>
    <option value="concentric">concentric</option>
    <option value="breadthfirst">breadth-first</option>
    <option value="circle">circle</option>
    <option value="grid">grid</option>
  </select>
</header>
<div id="main">
  <div id="cy"></div>
  <div id="panel"><div class="empty">Select a concept to view its details.</div></div>
</div>
<script id="okf-data" type="application/json">__DATA__</script>
<script>
const DATA = JSON.parse(document.getElementById('okf-data').textContent);
const byRel = {};
DATA.concepts.forEach(c => byRel[c.rel] = c);
const palette = ['#4f9cf9','#f97583','#9ece6a','#e0af68','#bb9af7','#7dcfff','#ff9e64','#73daca'];
const typeColors = {};
let ci = 0;
DATA.concepts.forEach(c => { if(!(c.type in typeColors)){ typeColors[c.type]=palette[ci++%palette.length]; }});

const elements = [];
DATA.concepts.forEach(c => elements.push({ data:{ id:c.rel, label:c.title, type:c.type }}));
DATA.edges.forEach(e => elements.push({ data:{ id:e.source+'->'+e.target, source:e.source, target:e.target }}));

const cy = cytoscape({
  container: document.getElementById('cy'),
  elements,
  style: [
    { selector:'node', style:{ 'background-color': ele=>typeColors[ele.data('type')]||'#4f9cf9',
      'label':'data(label)','color':'#e6edf3','font-size':'9px','text-valign':'bottom','text-margin-y':4,
      'width':18,'height':18,'text-max-width':'120px','text-wrap':'ellipsis' }},
    { selector:'edge', style:{ 'width':1,'line-color':'#3a4452','target-arrow-color':'#3a4452',
      'target-arrow-shape':'triangle','curve-style':'bezier','arrow-scale':0.8 }},
    { selector:'node:selected', style:{ 'border-width':3,'border-color':'#fff' }},
    { selector:'.dim', style:{ 'opacity':0.15 }},
  ],
  layout: { name:'cose', animate:false }
});

document.getElementById('counts').textContent =
  DATA.concepts.length + ' concepts · ' + DATA.edges.length + ' links · ' + Object.keys(typeColors).length + ' types';

const typeSel = document.getElementById('typeFilter');
Object.keys(typeColors).sort().forEach(t => { const o=document.createElement('option'); o.value=t; o.textContent=t; typeSel.appendChild(o); });

function rewireLinks(html, fromRel){
  const div = document.createElement('div'); div.innerHTML = html;
  div.querySelectorAll('a').forEach(a => {
    let href = a.getAttribute('href') || '';
    if(/^https?:|^mailto:|^#/.test(href)) { a.target='_blank'; return; }
    let target = href.split('#')[0];
    if(target.endsWith('/')) target += 'index.md';
    let resolved;
    if(target.startsWith('/')) resolved = target.replace(/^\\//,'');
    else { const base = fromRel.split('/').slice(0,-1).join('/');
      resolved = base ? base + '/' + target : target;
      const parts=[]; resolved.split('/').forEach(p=>{ if(p==='..')parts.pop(); else if(p!=='.')parts.push(p); }); resolved=parts.join('/'); }
    if(byRel[resolved]){ a.href='javascript:void(0)'; a.onclick=()=>select(resolved); }
    else { a.style.color='#f97583'; a.title='broken link'; }
  });
  return div.innerHTML;
}

function select(rel){
  const c = byRel[rel]; if(!c) return;
  cy.$(':selected').unselect();
  const node = cy.$id(rel); node.select();
  cy.elements().addClass('dim');
  node.removeClass('dim'); node.neighborhood().removeClass('dim');
  const backlinks = DATA.edges.filter(e=>e.target===rel).map(e=>e.source);
  const panel = document.getElementById('panel');
  panel.innerHTML =
    '<h2>'+escapeHtml(c.title)+'</h2>'+
    '<span class="pill">'+escapeHtml(c.type)+'</span>'+
    (c.tags||[]).map(t=>'<span class="pill">#'+escapeHtml(t)+'</span>').join('')+
    '<div class="kv">id: '+escapeHtml(c.id)+'</div>'+
    (c.description?'<div class="kv">'+escapeHtml(c.description)+'</div>':'')+
    (c.resource?'<div class="kv">resource: <a target="_blank" href="'+escapeAttr(c.resource)+'">'+escapeHtml(c.resource)+'</a></div>':'')+
    '<div class="body">'+rewireLinks(marked.parse(c.body||''), rel)+'</div>'+
    (backlinks.length?'<div class="backlinks"><strong>Cited by</strong>'+backlinks.map(b=>'<a onclick="select(\\''+b+'\\')">'+escapeHtml(byRel[b].title)+'</a>').join('')+'</div>':'');
  panel.scrollTop = 0;
}

function escapeHtml(s){ return (s||'').replace(/[&<>]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[m])); }
function escapeAttr(s){ return (s||'').replace(/"/g,'&quot;'); }
window.select = select;

cy.on('tap','node', e=>select(e.target.id()));
cy.on('tap', e=>{ if(e.target===cy){ cy.elements().removeClass('dim'); }});

function applyFilters(){
  const q = document.getElementById('search').value.toLowerCase();
  const t = typeSel.value;
  cy.nodes().forEach(n=>{
    const c = byRel[n.id()];
    const matchQ = !q || c.title.toLowerCase().includes(q) || c.id.toLowerCase().includes(q) || (c.tags||[]).join(' ').toLowerCase().includes(q);
    const matchT = !t || c.type===t;
    n.style('display', (matchQ&&matchT)?'element':'none');
  });
}
document.getElementById('search').addEventListener('input', applyFilters);
typeSel.addEventListener('change', applyFilters);
document.getElementById('layout').addEventListener('change', e=>{ cy.layout({name:e.target.value, animate:false}).run(); });
</script>
</body>
</html>
"""


def render(bundle_root: str, name: str) -> str:
    data = collect(bundle_root)
    payload = json.dumps(data).replace("</", "<\\/")  # avoid closing the script tag
    return (
        HTML_TEMPLATE
        .replace("__NAME__", name.replace("<", "&lt;").replace(">", "&gt;"))
        .replace("__DATA__", payload)
    )


def main() -> int:
    parser = argparse.ArgumentParser(description="Render an OKF bundle to a self-contained HTML viewer.")
    parser.add_argument("--bundle", required=True, help="Path to the OKF bundle directory.")
    parser.add_argument("--out", default=None, help="Output HTML path (default <bundle>/viz.html).")
    parser.add_argument("--name", default=None, help="Display name in the header.")
    args = parser.parse_args()

    if not os.path.isdir(args.bundle):
        print(f"Bundle directory not found: {args.bundle}", file=sys.stderr)
        return 1

    name = args.name or os.path.basename(os.path.abspath(args.bundle))
    out = args.out or os.path.join(args.bundle, "viz.html")
    html = render(args.bundle, name)
    with open(out, "w", encoding="utf-8") as fh:
        fh.write(html)
    print(f"Wrote {out}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
