//! `hephaes serve` — a local web app over the engine (plan §5).
//!
//! axum + server-side `pulldown-cmark` rendering. Pages/status are returned as
//! HTML; `ask` streams live progress then the rendered answer over SSE. Static
//! assets are embedded (rust-embed), so it's a single binary with no Node and no
//! runtime files. Minimal vanilla JS (no vendored libs) keeps it lean; richer
//! client libs (htmx/highlight.js/mermaid) are a documented later enhancement.

use super::{base_overrides, repo_root};
use crate::cli::{GlobalArgs, ServeArgs};
use crate::engine::{self, AskOptions, EngineCtx};
use anyhow::{bail, Result};
use axum::{
    extract::{Path as AxPath, Query, Request, State},
    http::{header, StatusCode},
    middleware::{self, Next},
    response::{
        sse::{Event, KeepAlive},
        Html, IntoResponse, Response, Sse,
    },
    routing::get,
    Router,
};
use hephaes_core::progress::{Event as ProgEvent, Progress};
use std::collections::HashMap;
use std::sync::Arc;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tokio_stream::StreamExt;

#[derive(rust_embed::RustEmbed)]
#[folder = "web/assets/"]
struct Assets;

struct AppState {
    ctx: EngineCtx,
    token: Option<String>,
}

pub async fn run(global: &GlobalArgs, args: ServeArgs) -> Result<()> {
    let root = repo_root(args.path.clone())?;
    let ov = base_overrides(global);
    let ctx = EngineCtx::load(&root, &ov)?;

    if !is_loopback(&args.host) && args.token.is_none() {
        bail!(
            "refusing to bind non-loopback host {} without --token (it would expose model spend)",
            args.host
        );
    }

    let state = Arc::new(AppState {
        ctx,
        token: args.token.clone(),
    });
    let app = router(state);
    let addr = format!("{}:{}", args.host, args.port);
    let listener = tokio::net::TcpListener::bind(&addr).await?;
    use owo_colors::OwoColorize;
    println!("{} http://{addr}", "hephaes serve →".green().bold());
    axum::serve(listener, app).await?;
    Ok(())
}

fn router(state: Arc<AppState>) -> Router {
    // Data/model endpoints are token-guarded (when a token is set); the static
    // shell + assets stay public so the page can load and then include the token.
    let api = Router::new()
        .route("/api/pages", get(api_pages))
        .route("/api/page", get(api_page))
        .route("/api/status", get(api_status))
        .route("/api/ask", get(api_ask))
        .layer(middleware::from_fn_with_state(state.clone(), auth));
    Router::new()
        .route("/", get(index))
        .route("/assets/:file", get(asset))
        .merge(api)
        .with_state(state)
}

/// When a token is configured, require `?token=…` matching it on API requests.
async fn auth(State(st): State<Arc<AppState>>, req: Request, next: Next) -> Response {
    if let Some(tok) = &st.token {
        let ok = req
            .uri()
            .query()
            .map(|q| query_has_token(q, tok))
            .unwrap_or(false);
        if !ok {
            return (StatusCode::UNAUTHORIZED, "unauthorized").into_response();
        }
    }
    next.run(req).await
}

fn query_has_token(query: &str, expected: &str) -> bool {
    query.split('&').any(|kv| {
        let mut it = kv.splitn(2, '=');
        it.next() == Some("token") && it.next() == Some(expected)
    })
}

fn is_loopback(host: &str) -> bool {
    matches!(host, "127.0.0.1" | "localhost" | "::1")
}

async fn index() -> impl IntoResponse {
    match Assets::get("index.html") {
        Some(f) => Html(String::from_utf8_lossy(&f.data).into_owned()).into_response(),
        None => (StatusCode::INTERNAL_SERVER_ERROR, "missing index.html").into_response(),
    }
}

async fn asset(AxPath(file): AxPath<String>) -> impl IntoResponse {
    match Assets::get(&file) {
        Some(f) => {
            let ct = content_type(&file);
            ([(header::CONTENT_TYPE, ct)], f.data.into_owned()).into_response()
        }
        None => (StatusCode::NOT_FOUND, "not found").into_response(),
    }
}

fn content_type(file: &str) -> &'static str {
    match file.rsplit('.').next().unwrap_or("") {
        "js" => "application/javascript; charset=utf-8",
        "css" => "text/css; charset=utf-8",
        "html" => "text/html; charset=utf-8",
        "svg" => "image/svg+xml",
        _ => "application/octet-stream",
    }
}

async fn api_pages(State(st): State<Arc<AppState>>) -> Html<String> {
    let pages = engine::list_pages(&st.ctx);
    if pages.is_empty() {
        return Html(
            "<p class=\"dim\">No wiki yet — run <code>hephaes init</code>.</p>".to_string(),
        );
    }
    const ORDER: &[&str] = &[
        "overview",
        "architecture",
        "module",
        "concept",
        "flow",
        "decision",
        "glossary",
        "answer",
    ];
    let mut out = String::new();
    let mut shown: std::collections::HashSet<String> = std::collections::HashSet::new();
    for kind in ORDER {
        let group: Vec<_> = pages.iter().filter(|p| p.kind == *kind).collect();
        if group.is_empty() {
            continue;
        }
        out.push_str(&format!(
            "<div class=\"section\">{}</div>",
            html_escape(kind)
        ));
        for p in group {
            out.push_str(&page_link(&p.rel, &p.title));
            shown.insert(p.rel.clone());
        }
    }
    let rest: Vec<_> = pages.iter().filter(|p| !shown.contains(&p.rel)).collect();
    if !rest.is_empty() {
        out.push_str("<div class=\"section\">other</div>");
        for p in rest {
            out.push_str(&page_link(&p.rel, &p.title));
        }
    }
    Html(out)
}

fn page_link(rel: &str, title: &str) -> String {
    format!(
        "<a href=\"#\" data-path=\"{}\">{}</a>",
        html_escape(rel),
        html_escape(title)
    )
}

async fn api_page(
    State(st): State<Arc<AppState>>,
    Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
    let path = params.get("path").map(String::as_str).unwrap_or("");
    match engine::read_page(&st.ctx, path) {
        Ok(md) => Html(render_wiki_html(&st.ctx, &md)).into_response(),
        Err(e) => (
            StatusCode::BAD_REQUEST,
            Html(format!(
                "<p class=\"err\">{}</p>",
                html_escape(&format!("{e:#}"))
            )),
        )
            .into_response(),
    }
}

async fn api_status(State(st): State<Arc<AppState>>) -> Html<String> {
    Html(format!(
        "<pre>{}</pre>",
        html_escape(&engine::status_text(&st.ctx))
    ))
}

async fn api_ask(
    State(st): State<Arc<AppState>>,
    Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
    let q = params.get("q").cloned().unwrap_or_default();
    let flag = |k: &str| {
        params
            .get(k)
            .map(|v| v == "1" || v == "true")
            .unwrap_or(false)
    };
    let from_source = flag("from_source");
    let save = flag("save");

    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
    let prog = SseProgress { tx: tx.clone() };

    tokio::spawn(async move {
        let opts = AskOptions { from_source, save };
        let answer_html = match engine::ask(&st.ctx, &q, &opts, &prog).await {
            Ok(r) => {
                if let Some(rel) = &r.saved_page {
                    let _ = tx.send(
                        Event::default()
                            .event("progress")
                            .data(format!("✓ saved to {rel}")),
                    );
                }
                render_wiki_html(&st.ctx, &r.answer)
            }
            Err(e) => format!(
                "<p class=\"err\">error: {}</p>",
                html_escape(&format!("{e:#}"))
            ),
        };
        let _ = tx.send(Event::default().event("answer").data(answer_html));
        let _ = tx.send(Event::default().event("done").data("1"));
    });

    let stream = UnboundedReceiverStream::new(rx).map(Ok::<Event, std::convert::Infallible>);
    Sse::new(stream).keep_alive(KeepAlive::default())
}

/// Streams agent progress to the browser as SSE `progress` events.
struct SseProgress {
    tx: tokio::sync::mpsc::UnboundedSender<Event>,
}

impl Progress for SseProgress {
    fn event(&self, ev: ProgEvent) {
        let line = match ev {
            ProgEvent::Tool { name, target } if target.is_empty() => format!("→ {name}"),
            ProgEvent::Tool { name, target } => format!("→ {name} {target}"),
            ProgEvent::Page { path } => format!("✓ {path}"),
            ProgEvent::Note(t) => {
                format!("· {}", hephaes_core::util::truncate_chars(t.trim(), 160))
            }
        };
        let _ = self.tx.send(Event::default().event("progress").data(line));
    }
}

fn render_md(md: &str) -> String {
    use pulldown_cmark::{html, Event, Options, Parser};
    // Enable common extensions but NOT ENABLE_WIKILINKS — hephaes resolves
    // `[[wikilinks]]` itself (see resolve_wikilinks), so they must reach us as
    // literal text. Also neutralize raw HTML (an LLM answer or wiki page could
    // contain `<img onerror=…>`) by treating raw-HTML events as text so
    // push_html escapes them — prevents XSS in the innerHTML-rendered UI.
    let mut opts = Options::empty();
    opts.insert(Options::ENABLE_TABLES);
    opts.insert(Options::ENABLE_STRIKETHROUGH);
    opts.insert(Options::ENABLE_TASKLISTS);
    opts.insert(Options::ENABLE_FOOTNOTES);
    let parser = Parser::new_ext(md, opts).map(|ev| match ev {
        Event::Html(h) | Event::InlineHtml(h) => Event::Text(h),
        other => other,
    });
    let mut out = String::new();
    html::push_html(&mut out, parser);
    out
}

/// Render markdown to HTML, then turn `[[wikilinks]]` into in-app page links.
fn render_wiki_html(ctx: &EngineCtx, md: &str) -> String {
    resolve_wikilinks(ctx, &render_md(md))
}

/// Replace `[[Target]]` / `[[Target|Alias]]` with links to resolved pages;
/// unresolved links are left as escaped text.
fn resolve_wikilinks(ctx: &EngineCtx, html: &str) -> String {
    let pages = engine::list_pages(ctx);
    let mut map: HashMap<String, String> = HashMap::new();
    for p in &pages {
        let stem = p.rel.trim_end_matches(".md");
        let base = stem.rsplit('/').next().unwrap_or(stem);
        for key in [p.rel.as_str(), stem, base, p.title.as_str()] {
            map.entry(key.to_lowercase())
                .or_insert_with(|| p.rel.clone());
        }
    }
    let re = regex::Regex::new(r"\[\[([^\]]+)\]\]").unwrap();
    re.replace_all(html, |c: &regex::Captures| {
        let inner = &c[1];
        let (target, label) = match inner.split_once('|') {
            Some((t, a)) => (t.trim(), a.trim()),
            None => (inner.trim(), inner.trim()),
        };
        let key = target.to_lowercase();
        match map.get(&key).or_else(|| map.get(&format!("{key}.md"))) {
            Some(rel) => format!(
                "<a href=\"#\" data-path=\"{}\">{}</a>",
                html_escape(rel),
                html_escape(label)
            ),
            None => html_escape(&format!("[[{inner}]]")),
        }
    })
    .into_owned()
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{Config, PerCommandModels, ProviderId};
    use axum::body::Body;
    use axum::http::Request;
    use std::path::PathBuf;
    use tower::ServiceExt;

    fn cfg() -> Config {
        Config {
            provider: ProviderId::Anthropic,
            model: Some("mock".to_string()),
            models: PerCommandModels::default(),
            wiki_dir: "wiki".to_string(),
            ollama_url: "http://localhost:11434".to_string(),
            concurrency: 1,
            include: vec![],
            exclude: vec![],
            max_file_bytes: 1 << 20,
        }
    }

    fn app_with(name: &str, token: Option<String>) -> (PathBuf, Router) {
        let dir = std::env::temp_dir().join(format!("hephaes-web-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("wiki")).unwrap();
        std::fs::write(dir.join("wiki/index.md"), "# Index\n").unwrap();
        std::fs::write(
            dir.join("wiki/overview.md"),
            "---\ntitle: Overview\ntype: overview\n---\n\n# Overview\nThe **demo** project.\n",
        )
        .unwrap();
        let ctx = EngineCtx::new(dir.clone(), cfg());
        (dir, router(Arc::new(AppState { ctx, token })))
    }

    fn app_fixture(name: &str) -> (PathBuf, Router) {
        app_with(name, None)
    }

    async fn get(app: Router, uri: &str) -> (StatusCode, String) {
        let resp = app
            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
            .await
            .unwrap();
        let status = resp.status();
        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
            .await
            .unwrap();
        (status, String::from_utf8_lossy(&bytes).into_owned())
    }

    #[tokio::test]
    async fn index_is_served() {
        let (dir, app) = app_fixture("index");
        let (status, body) = get(app, "/").await;
        assert_eq!(status, StatusCode::OK);
        assert!(body.contains("hephaes"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn pages_lists_links() {
        let (dir, app) = app_fixture("pages");
        let (status, body) = get(app, "/api/pages").await;
        assert_eq!(status, StatusCode::OK);
        assert!(body.contains("data-path=\"overview.md\""));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn page_renders_markdown() {
        let (dir, app) = app_fixture("page");
        let (status, body) = get(app, "/api/page?path=overview.md").await;
        assert_eq!(status, StatusCode::OK);
        assert!(body.contains("<strong>demo</strong>")); // markdown rendered to HTML
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn page_escape_is_rejected() {
        let (dir, app) = app_fixture("escape");
        let (status, _) = get(app, "/api/page?path=../escape.md").await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn status_route_returns_text() {
        let (dir, app) = app_fixture("status");
        let (status, body) = get(app, "/api/status").await;
        assert_eq!(status, StatusCode::OK);
        assert!(body.contains("repository:"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn wikilinks_render_as_links() {
        let (dir, app) = app_fixture("wikilink");
        std::fs::write(dir.join("wiki/note.md"), "# Note\nSee [[Overview]].\n").unwrap();
        let (status, body) = get(app, "/api/page?path=note.md").await;
        assert_eq!(status, StatusCode::OK);
        assert!(body.contains("data-path=\"overview.md\""), "got: {body}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn token_required_for_api_but_not_static() {
        let (dir, app) = app_with("token", Some("secret".to_string()));
        // API without token → 401
        let (s1, _) = get(app.clone(), "/api/status").await;
        assert_eq!(s1, StatusCode::UNAUTHORIZED);
        // API with correct token → 200
        let (s2, _) = get(app.clone(), "/api/status?token=secret").await;
        assert_eq!(s2, StatusCode::OK);
        // static shell stays public
        let (s3, _) = get(app, "/").await;
        assert_eq!(s3, StatusCode::OK);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn ask_streams_answer_and_done() {
        // No wiki → engine::ask errors before any network; we only assert the
        // SSE framing (answer + done events) is emitted.
        let dir = std::env::temp_dir().join(format!("hephaes-web-sse-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let ctx = EngineCtx::new(dir.clone(), cfg());
        let app = router(Arc::new(AppState { ctx, token: None }));
        let (status, body) = get(app, "/api/ask?q=test").await;
        assert_eq!(status, StatusCode::OK);
        assert!(body.contains("event: answer"), "got: {body}");
        assert!(body.contains("event: done"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn asset_is_served() {
        let (dir, app) = app_fixture("asset");
        let (status, body) = get(app, "/assets/app.js").await;
        assert_eq!(status, StatusCode::OK);
        assert!(body.contains("EventSource"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn raw_html_in_markdown_is_escaped() {
        let out = render_md("Hi <img src=x onerror=alert(1)> there");
        assert!(!out.contains("<img"), "raw HTML must not pass through: {out}");
        assert!(out.contains("&lt;img"));
    }
}
