//! Adom Wiki as the FIRST part-source on the sourcing ladder.
//!
//! The wiki (https://wiki.adom.inc) hosts published `component` pages whose git
//! repo carries a complete, Adom-vetted CAD bundle — `<mpn>.kicad_sym`,
//! `<mpn>.kicad_mod`, STEP/GLB, datasheet PDF, and the 3D-outline SVGs. If a
//! part already exists there we REUSE that bundle instead of re-fetching and
//! rebuilding from external vendors: faster, free, and guaranteed
//! Adom-consistent geometry + provenance.
//!
//! Reads only — the public read API needs no token. There is no HTTP crate in
//! this tree, so we shell `curl` (the same dependency-free style the rest of the
//! CLI uses to drive sibling binaries).

use crate::import::{self, FileProvenance};
use crate::sourcing::{self, Source};
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use std::path::{Path, PathBuf};

const WIKI: &str = "https://wiki.adom.inc";

/// A wiki `component` page that matched an MPN and carries a usable bundle.
pub struct WikiHit {
    pub slug: String,
    pub title: String,
    pub url: String,          // human page URL — recorded as provenance
}

/// Normalize an MPN for matching: lowercase, drop all non-alphanumerics. So
/// "B2B-ZR" == "b2bzr" == "B2B_ZR". Used to compare the requested MPN against a
/// page's component.mpn and to match canonical file prefixes.
fn norm(s: &str) -> String {
    s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()
}

/// Result of a successful reuse-from-wiki.
pub struct WikiPull {
    pub url: String,
    pub files_added: Vec<String>,
}

// ---------- tiny curl-backed HTTP (no HTTP crate in-tree) ----------

fn curl_json(url: &str) -> Option<Value> {
    let out = std::process::Command::new("curl")
        .args(["-fsSL", "--max-time", "20", url])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    serde_json::from_slice(&out.stdout).ok()
}

fn curl_download(url: &str, dest: &Path) -> Result<()> {
    let out = std::process::Command::new("curl")
        .args(["-fsSL", "--max-time", "60", "-o"])
        .arg(dest)
        .arg(url)
        .output()
        .with_context(|| format!("curl {url}"))?;
    if !out.status.success() {
        return Err(anyhow!(
            "curl {url} failed: {}",
            String::from_utf8_lossy(&out.stderr)
        ));
    }
    Ok(())
}

// ---------- matching ----------

/// Candidate slugs to try as an exact page lookup, most-specific first.
fn slug_candidates(mpn: &str) -> Vec<String> {
    let lower = mpn.to_ascii_lowercase();
    let alnum: String = lower.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
    let dashed: String = lower
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
        .collect();
    let mut v = vec![lower.clone(), dashed, alnum];
    v.dedup();
    v
}

/// The MPN with hyphens stripped — the wiki's full-text search throws on
/// hyphens (known bug), so we never send a raw hyphenated query.
fn search_safe(mpn: &str) -> String {
    mpn.chars().filter(|c| *c != '-').collect()
}

fn url_encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
            _ => out.push_str(&format!("%{:02X}", b)),
        }
    }
    out
}

/// Is this page a `component` page? Returns (title, human URL, component.mpn).
fn component_meta(slug: &str) -> Option<(String, String, String)> {
    let pj = curl_json(&format!("{WIKI}/api/v1/pages/{slug}/files/page.json"))?;
    if pj.get("type").and_then(|t| t.as_str()) != Some("component") {
        return None;
    }
    let title = pj.get("title").and_then(|t| t.as_str()).unwrap_or(slug).to_string();
    // The canonical MPN: component.mpn, falling back to the slug.
    let page_mpn = pj
        .get("component")
        .and_then(|c| c.get("mpn"))
        .and_then(|m| m.as_str())
        .filter(|s| !s.is_empty())
        .unwrap_or(slug)
        .to_string();
    Some((title, page_url(slug, &pj), page_mpn))
}

/// Human page URL — `wiki.adom.inc/<org>/<slug>` when we know the org, else
/// `wiki.adom.inc/<slug>`.
fn page_url(slug: &str, meta: &Value) -> String {
    let org = meta
        .get("org_name")
        .or_else(|| meta.get("org").and_then(|o| o.get("name")))
        .and_then(|o| o.as_str())
        .unwrap_or("");
    if org.is_empty() {
        format!("{WIKI}/{slug}")
    } else {
        format!("{WIKI}/{org}/{slug}")
    }
}

/// Collect candidate component slugs for an MPN via the three documented paths,
/// in priority order: exact slug → discover(triggers) → full-text search.
fn candidate_slugs(mpn: &str) -> Vec<String> {
    fn push(s: String, slugs: &mut Vec<String>) {
        if !s.is_empty() && !slugs.contains(&s) {
            slugs.push(s);
        }
    }
    let mut slugs: Vec<String> = Vec::new();

    // 1. exact / normalized slug
    for cand in slug_candidates(mpn) {
        push(cand, &mut slugs);
    }

    // 2. discovery by trigger
    if let Some(v) = curl_json(&format!("{WIKI}/api/v1/discover?triggers={}", url_encode(mpn))) {
        if let Some(arr) = v.as_array() {
            for r in arr {
                if r.get("type").and_then(|t| t.as_str()) == Some("component") {
                    if let Some(s) = r.get("slug").and_then(|s| s.as_str()) {
                        push(s.to_string(), &mut slugs);
                    }
                }
            }
        }
    }

    // 3. full-text search (hyphens stripped — FTS throws on them)
    if let Some(v) = curl_json(&format!("{WIKI}/api/v1/search?q={}", url_encode(&search_safe(mpn)))) {
        if let Some(arr) = v.get("results").and_then(|r| r.as_array()) {
            for r in arr {
                if r.get("type").and_then(|t| t.as_str()) == Some("component") {
                    if let Some(s) = r.get("slug").and_then(|s| s.as_str()) {
                        push(s.to_string(), &mut slugs);
                    }
                }
            }
        }
    }

    slugs
}

/// List a page's file paths (flat). Handles `{ "files": [ { "path": .. } ] }`.
fn list_files(slug: &str) -> Vec<String> {
    let v = match curl_json(&format!("{WIKI}/api/v1/pages/{slug}/files")) {
        Some(v) => v,
        None => return vec![],
    };
    let arr = v
        .get("files")
        .and_then(|f| f.as_array())
        .or_else(|| v.as_array())
        .cloned()
        .unwrap_or_default();
    arr.iter()
        .filter(|e| e.get("type").and_then(|t| t.as_str()) != Some("tree"))
        .filter_map(|e| {
            e.get("path")
                .or_else(|| e.get("name"))
                .and_then(|p| p.as_str())
                .map(|s| s.to_string())
        })
        .collect()
}

/// How a wiki file maps into the bundle. `Core` goes through the importer
/// (magic-byte validation + canonical `<mpn>.<ext>` naming + per-source
/// variant); `Aux(name)` is copied in directly under `name`.
enum Pull {
    Core,
    Aux(String),
}

/// Classify a wiki file for pulling. `mpn` is the requested MPN — used both to
/// match the canonical file prefix and to name targets. (We key off the
/// requested MPN, not the page's component.mpn, because the on-disk wiki files
/// are named after the real part number and some pages glue a description onto
/// component.mpn.) Only the CANONICAL file of each type is pulled — variant /
/// compare GLBs like `B2B-ZR-v2.glb` or `B2B-ZR-compare.glb` are skipped.
fn classify(mpn: &str, path: &str) -> Option<Pull> {
    let base = path.rsplit('/').next().unwrap_or(path);
    let bl = base.to_ascii_lowercase();
    let pm = norm(mpn);

    // 3D-outline SVGs: <prefix>-3d-outline-<style>.svg, prefix == the part.
    if bl.ends_with(".svg") {
        if let Some(idx) = bl.find("-3d-outline-") {
            if norm(&base[..idx]) == pm {
                return Some(Pull::Aux(format!("{mpn}{}", &base[idx..])));
            }
        }
        return None;
    }

    // Everything else keys on (stem, ext); stem must BE the part (not a variant).
    let (stem, ext) = base.rsplit_once('.')?;
    if norm(stem) != pm {
        return None;
    }
    match ext.to_ascii_lowercase().as_str() {
        "kicad_sym" | "kicad_mod" | "step" | "stp" | "pdf" => Some(Pull::Core),
        "glb" => Some(Pull::Aux(format!("{mpn}.glb"))),
        "wrl" => Some(Pull::Aux(format!("{mpn}.wrl"))),
        _ => None,
    }
}

/// Is `path` a core CAD file for `mpn`? Its presence is what makes a page a
/// USABLE bundle (a metadata-only component page has none → fall through).
fn has_core(mpn: &str, path: &str) -> bool {
    matches!(classify(mpn, path), Some(Pull::Core))
        && !path.to_ascii_lowercase().ends_with(".pdf")
}

/// Find the component page that actually carries a CAD bundle AND whose MPN
/// matches the request (normalized). Without the MPN check, a loose
/// discover/search hit (or the wiki returning a popular page) would wrongly
/// "match" any query — verified the hard way: a nonsense MPN matched B2B-ZR.
pub fn find_component(mpn: &str) -> Option<WikiHit> {
    let want = norm(mpn);
    for slug in candidate_slugs(mpn) {
        let (title, url, page_mpn) = match component_meta(&slug) {
            Some(m) => m,
            None => continue, // not a component page (or 404)
        };
        // Match on the page's component.mpn OR its slug — some pages glue a
        // description onto component.mpn (e.g. "BQ7692003PWR — BQ76920 …"), so the
        // clean slug is the reliable identity. Either exact (normalized) match is
        // accepted; a loose discover/search hit for a different part is rejected.
        if norm(&page_mpn) != want && norm(&slug) != want {
            continue;
        }
        let files = list_files(&slug);
        if files.iter().any(|f| has_core(mpn, f)) {
            return Some(WikiHit { slug, title, url });
        }
        // matches the MPN but metadata-only (no CAD) → not usable, keep looking
    }
    None
}

// ---------- pulling the bundle ----------

/// Query the wiki and, if a usable component bundle exists, pull it into
/// `library/<mpn>/` with canonical names + `adom-wiki` provenance. Returns
/// `Ok(None)` when nothing usable matched (→ caller falls through to the
/// external ladder). Reads only.
pub fn try_fetch(mpn: &str) -> Result<Option<WikiPull>> {
    let hit = match find_component(mpn) {
        Some(h) => h,
        None => return Ok(None),
    };
    println!("Adom Wiki: reusing published bundle \"{}\" from {} ({})", hit.title, hit.url, hit.slug);

    let dir = crate::library::ensure_dir(mpn)?;
    let stage = std::env::temp_dir().join(format!("cf-wiki-{}", mpn));
    let _ = std::fs::remove_dir_all(&stage);
    std::fs::create_dir_all(&stage)?;

    let prov = FileProvenance {
        discovery_source: "adom-wiki".into(),
        content_origin: "adom-wiki".into(),
        url: hit.url.clone(),
    };

    let mut added: Vec<String> = Vec::new();
    for path in list_files(&hit.slug) {
        let pull = match classify(mpn, &path) {
            Some(p) => p,
            None => continue, // README / page.json / variant GLB / unrelated file
        };
        let base = path.rsplit('/').next().unwrap_or(&path).to_string();
        let staged: PathBuf = stage.join(&base);
        let file_url = format!(
            "{WIKI}/api/v1/pages/{}/files/{}",
            hit.slug,
            path.split('/').map(url_encode).collect::<Vec<_>>().join("/")
        );
        if let Err(e) = curl_download(&file_url, &staged) {
            eprintln!("WARN: skipped {base}: {e}");
            continue;
        }
        match pull {
            Pull::Core => {
                // Core CAD + datasheet → through the importer: magic-byte
                // validation, canonical <mpn>.<ext> naming, per-source variant,
                // provenance.
                match import::import_path_with_provenance(&staged, Some(mpn), Some(&prov)) {
                    Ok(rep) => added.extend(rep.files_added),
                    Err(e) => eprintln!("WARN: import {base} failed: {e}"),
                }
            }
            Pull::Aux(name) => {
                // GLB / WRL / 3D-outline SVGs → copy in directly + record provenance.
                let target = dir.join(&name);
                if let Err(e) = std::fs::copy(&staged, &target) {
                    eprintln!("WARN: copy {base} failed: {e}");
                    continue;
                }
                let _ = import::record_file_provenance(&dir, &name, &prov);
                added.push(name);
            }
        }
    }
    let _ = std::fs::remove_dir_all(&stage);

    // Record the wiki as the (winning) ladder step + stamp info.json provenance.
    let _ = sourcing::append_chain(mpn, Source::AdomWiki, "OK", Some(&hit.url));
    let _ = stamp_info_provenance(&dir, mpn, &hit.url);

    if added.is_empty() {
        // Found a page but pulled nothing usable after all → fall through.
        return Ok(None);
    }
    Ok(Some(WikiPull {
        url: hit.url,
        files_added: added,
    }))
}

/// Record the top-level discovery provenance on info.json so the source carat
/// shows "Adom Wiki" as a selectable method like the other ladder sources.
fn stamp_info_provenance(dir: &Path, mpn: &str, url: &str) -> Result<()> {
    let path = dir.join("info.json");
    let mut info: Value = std::fs::read_to_string(&path)
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_else(|| serde_json::json!({ "mpn": mpn }));
    info["discovery_source"] = Value::String("adom-wiki".into());
    info["content_origin"] = Value::String("adom-wiki".into());
    info["adom_wiki_url"] = Value::String(url.into());
    std::fs::write(&path, serde_json::to_string_pretty(&info)?)?;
    Ok(())
}