use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::library;

/// Per-file upstream provenance — which ladder source + URL each file
/// came from. Passed into import_path and written to
/// `<MPN>-file-provenance.json` so downstream tools (chipsmith tooltips,
/// wiki chip pages, concur cross-validation notes) can attribute every
/// artifact to its origin without guessing.
#[derive(Clone, Default)]
pub struct FileProvenance {
    pub discovery_source: String, // where did I FIND the link? ("digikey", "mouser", etc.)
    pub content_origin: String,   // who actually MADE the file? ("manufacturer", "ultralibrarian", etc.)
    pub url: String,              // the download URL the bytes came from
}

pub struct ImportReport {
    pub dest_dir: PathBuf,
    pub files_added: Vec<String>,
}

/// Import with provenance tracking. If `provenance` is Some, every file
/// that lands gets an entry in `<MPN>-file-provenance.json` mapping
/// `"<MPN>.kicad_sym" → { source, url, fetched_at }`. Chipsmith reads
/// this to show "source: SnapMagic" in the footprint-hover tooltip.
pub fn import_path_with_provenance(
    src: &Path,
    mpn_override: Option<&str>,
    provenance: Option<&FileProvenance>,
) -> Result<ImportReport> {
    let report = import_path(src, mpn_override)?;
    if let Some(prov) = provenance {
        for filename in &report.files_added {
            let _ = write_file_provenance(&report.dest_dir, filename, prov);
        }
        // Keep a per-source VARIANT of the symbol/footprint so multiple sources
        // coexist (the provenance carat lists each). Variant filename:
        // <mpn>.<method>.kicad_sym, where method = content_origin (who made it).
        let method = slugify(if !prov.content_origin.is_empty() { &prov.content_origin } else { &prov.discovery_source });
        if !method.is_empty() && method != "ds2sf" {
            if let Some(mpn) = report.dest_dir.file_name().and_then(|n| n.to_str()) {
                for ext in ["kicad_sym", "kicad_mod"] {
                    let canon = format!("{mpn}.{ext}");
                    if report.files_added.iter().any(|f| f == &canon) {
                        let variant = report.dest_dir.join(format!("{mpn}.{method}.{ext}"));
                        let _ = std::fs::copy(report.dest_dir.join(&canon), &variant);
                    }
                }
            }
        }
    }
    Ok(report)
}

/// Lowercase a source slug to safe filename chars (e.g. "Ultra Librarian" →
/// "ultralibrarian"), so variant filenames are predictable.
fn slugify(s: &str) -> String {
    s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()
}

pub fn import_path(src: &Path, mpn_override: Option<&str>) -> Result<ImportReport> {
    if !src.exists() {
        return Err(anyhow!("path does not exist: {}", src.display()));
    }

    let mpn = match mpn_override {
        Some(m) => m.to_string(),
        None => infer_mpn(src)
            .ok_or_else(|| anyhow!("could not infer MPN from path '{}' — pass --mpn", src.display()))?,
    };

    let dest = library::ensure_dir(&mpn)?;
    let mut added = Vec::new();

    if src.is_file() {
        ingest_file(src, &mpn, &dest, &mut added)?;
    } else if src.is_dir() {
        ingest_dir(src, &mpn, &dest, &mut added)?;
    }

    Ok(ImportReport { dest_dir: dest, files_added: added })
}

fn ingest_file(file: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {
    let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
    match ext.as_str() {
        "zip" => unzip_and_ingest(file, mpn, dest, added),
        "step" | "stp" | "pdf" | "kicad_mod" | "kicad_sym" | "wrl" => {
            // Magic-byte validation. Catches the "naked curl returned a
            // 174 KB HTML denial page that looks like 200 OK" footgun —
            // PDFs are common, STEPs are common, and both have unambiguous
            // magic strings. Refuse the import if the bytes don't match.
            validate_magic_bytes(file, &ext)
                .with_context(|| format!("magic-byte check failed for {}", file.display()))?;

            let target_ext = if ext == "stp" { "step" } else { ext.as_str() };
            let target_name = format!("{mpn}.{target_ext}");
            let target = dest.join(&target_name);
            std::fs::copy(file, &target).with_context(|| format!("copying {} → {}", file.display(), target.display()))?;
            added.push(target_name.clone());
            // Per-MPN fetch-log: append a structured entry every time a
            // file lands. Audit trail without trusting AI memory.
            let _ = append_fetch_log(dest, mpn, &target_name, file);
            Ok(())
        }
        // Fusion 360 + Altium. Inventory checks exact-case filenames
        // (<MPN>.lbr, <MPN>.SchLib, <MPN>.PcbLib, <MPN>.IntLib), so we
        // preserve the canonical case on the way in.
        "lbr" | "schlib" | "pcblib" | "intlib" | "libpkg" => {
            let target_ext = match ext.as_str() {
                "lbr" => "lbr",
                "schlib" => "SchLib",
                "pcblib" => "PcbLib",
                "intlib" => "IntLib",
                "libpkg" => "LibPkg",
                _ => ext.as_str(),
            };
            let target_name = format!("{mpn}.{target_ext}");
            let target = dest.join(&target_name);
            std::fs::copy(file, &target).with_context(|| format!("copying {} → {}", file.display(), target.display()))?;
            added.push(target_name.clone());
            let _ = append_fetch_log(dest, mpn, &target_name, file);
            Ok(())
        }
        _ => Ok(()),
    }
}

fn ingest_dir(dir: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {
    for entry in walk_recursive(dir)? {
        ingest_file(&entry, mpn, dest, added)?;
    }
    Ok(())
}

fn unzip_and_ingest(zip: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {
    let tmp = tempfile_dir()?;
    let status = Command::new("unzip")
        .arg("-q").arg("-o")
        .arg(zip)
        .arg("-d").arg(&tmp)
        .status()
        .context("running unzip — install `unzip` if missing")?;
    if !status.success() {
        return Err(anyhow!("unzip failed for {}", zip.display()));
    }
    ingest_dir(&tmp, mpn, dest, added)?;
    let _ = std::fs::remove_dir_all(&tmp);
    Ok(())
}

fn tempfile_dir() -> Result<PathBuf> {
    let base = std::env::temp_dir().join(format!("adom-chip-fetcher-{}", std::process::id()));
    std::fs::create_dir_all(&base)?;
    Ok(base)
}

fn walk_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
    let mut out = Vec::new();
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        for entry in std::fs::read_dir(&d)? {
            let entry = entry?;
            let p = entry.path();
            if p.is_dir() { stack.push(p); }
            else { out.push(p); }
        }
    }
    Ok(out)
}

/// Infer an MPN from the path. Looks at the filename (and parent zip name)
/// for a token that looks like a part number — letters mixed with digits.
fn infer_mpn(path: &Path) -> Option<String> {
    let stem = path.file_stem()?.to_str()?;
    for token in stem.split(|c: char| c == '-' || c == '_' || c == ' ' || c == '.') {
        if looks_like_mpn(token) { return Some(token.to_string()); }
    }
    None
}

/// Public wrapper so other part-sources (e.g. the Adom Wiki puller) can record
/// provenance for files they copy in directly — the GLB and 3D-outline SVGs that
/// don't pass through `import_path` (which only ingests core CAD + datasheet).
pub fn record_file_provenance(dest: &Path, filename: &str, prov: &FileProvenance) -> Result<()> {
    write_file_provenance(dest, filename, prov)
}

/// Append a per-file provenance entry to `<MPN>-file-provenance.json`.
/// Chipsmith reads this to show "source: SnapMagic" in the footprint
/// tooltip. Format:
/// ```json
/// { "ADS1115IDGSR.kicad_sym": { "source": "snapmagic", "url": "...", "fetched_at": 1778... },
///   "ADS1115IDGSR.step":      { "source": "manufacturer", "url": "...", "fetched_at": 1778... } }
/// ```
fn write_file_provenance(dest: &Path, filename: &str, prov: &FileProvenance) -> Result<()> {
    use std::time::{SystemTime, UNIX_EPOCH};
    let mpn = dest.file_name().and_then(|n| n.to_str()).unwrap_or("?");
    let path = dest.join(format!("{mpn}-file-provenance.json"));
    let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {
        let s = std::fs::read_to_string(&path).unwrap_or_default();
        serde_json::from_str(&s).unwrap_or_default()
    } else {
        serde_json::Map::new()
    };
    let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
    map.insert(filename.to_string(), serde_json::json!({
        "discovery_source": prov.discovery_source,
        "content_origin": prov.content_origin,
        "url": prov.url,
        "fetched_at": now,
    }));
    std::fs::write(&path, serde_json::to_string_pretty(&serde_json::Value::Object(map))?)?;
    Ok(())
}

/// The core artifacts whose origin we insist on knowing. (Present-on-disk files
/// without a recorded source are the "lazy fetch" failure.)
fn core_files(mpn: &str) -> Vec<String> {
    vec![
        format!("{mpn}.step"), format!("{mpn}.kicad_sym"), format!("{mpn}.kicad_mod"),
        format!("{mpn}.lbr"), format!("{mpn}.SchLib"), format!("{mpn}.PcbLib"),
        format!("{mpn}.IntLib"), format!("{mpn}.pdf"),
    ]
}

/// Files present in `dir` that have NO recorded provenance entry. Empty = good.
pub fn unprovenanced_files(dir: &Path, mpn: &str) -> Vec<String> {
    let prov: serde_json::Map<String, serde_json::Value> =
        std::fs::read_to_string(dir.join(format!("{mpn}-file-provenance.json")))
            .ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
    core_files(mpn).into_iter()
        .filter(|f| dir.join(f).is_file())
        .filter(|f| {
            // present, but no entry OR an entry with no real source recorded
            match prov.get(f) {
                None => true,
                Some(e) => {
                    let d = e.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("");
                    let o = e.get("content_origin").and_then(|v| v.as_str()).unwrap_or("");
                    (d.is_empty() || d == "unknown") && (o.is_empty() || o == "unknown")
                }
            }
        })
        .collect()
}

/// Backfill provenance for files that landed without going through import (the
/// B2B-XH-A "heartbeat stub" case). Derives each present-but-un-provenanced
/// file's source from the strongest signal on info.json — `step_source` for the
/// STEP, else the `fetched_via_chain` entry that succeeded — and records it
/// (tagged `derived: true`). When nothing is recoverable it writes
/// `discovery_source: "unknown"` so the gate keeps flagging it for a refetch
/// instead of silently passing. Returns (recovered, still_unknown) filenames.
pub fn backfill_dir(dir: &Path, mpn: &str) -> Result<(Vec<String>, Vec<String>)> {
    use std::time::{SystemTime, UNIX_EPOCH};
    let info: serde_json::Value = std::fs::read_to_string(dir.join("info.json"))
        .ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(serde_json::json!({}));
    // The chain entry that actually succeeded = where files were found.
    let chain_src = info.get("fetched_via_chain").and_then(|c| c.as_array()).and_then(|arr| {
        arr.iter().rev().find_map(|e| {
            let o = e.get("outcome").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
            (o.contains("ok") || o.contains("success") || o.contains("got"))
                .then(|| e.get("source").and_then(|v| v.as_str()).unwrap_or("").to_string())
                .filter(|s| !s.is_empty())
        })
    });
    let step_src = info.get("step_source").and_then(|v| v.as_str())
        .filter(|s| !s.is_empty() && *s != "—" && s.to_lowercase() != "none").map(String::from);

    let path = dir.join(format!("{mpn}-file-provenance.json"));
    let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {
        serde_json::from_str(&std::fs::read_to_string(&path).unwrap_or_default()).unwrap_or_default()
    } else { serde_json::Map::new() };
    let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
    let (mut recovered, mut unknown) = (Vec::new(), Vec::new());

    // SELF-HEAL the datasheet source for the ONE provably-impossible case: the
    // KiCad standard library ships symbols / footprints / 3D models and ZERO
    // datasheets, so a `.pdf` tagged `kicad*` is a guaranteed wholesale mis-record
    // (the whole JST/connector bundle got stamped with the KiCad-library source,
    // datasheet included). The datasheet actually came from the manufacturer in
    // that fetch, so re-attribute it to a manufacturer-tagged sibling (the STEP).
    // John: "how could a pdf source come from std kicad library? i ONLY want the
    // pdf from the manufacturer website." We do NOT touch aggregator-tagged PDFs
    // (CSE / SnapEDA / Mouser) — those genuinely host datasheets, so relabeling
    // them "manufacturer" would be a lie; the UI honestly flags them instead.
    let impossible_for_datasheet = |s: &str|
        matches!(s.to_lowercase().as_str(), "kicad" | "kicad-library" | "kicad_community");
    let mfr_sibling = map.values().find(|e| {
        let o = e.get("content_origin").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
        let d = e.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
        // a sibling downloaded from the maker's OWN site (not an aggregator/KiCad)
        matches!(o.as_str(), "manufacturer" | "mfr" | "vendor")
            && !impossible_for_datasheet(&d)
            && !matches!(d.as_str(), "cse" | "componentsearch" | "cse_ul" | "samacsys"
                | "snapeda" | "snapmagic" | "ultralibrarian" | "ul" | "mouser"
                | "digikey" | "arrow" | "lcsc")
    }).cloned();
    let pdf_name = format!("{mpn}.pdf");
    if let Some(entry) = map.get(&pdf_name).cloned() {
        let disc = entry.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("");
        let orig = entry.get("content_origin").and_then(|v| v.as_str()).unwrap_or("");
        // Only act on the impossible case, and only when we have a genuine
        // manufacturer-direct sibling to copy — otherwise leave it truthful.
        if (impossible_for_datasheet(disc) || impossible_for_datasheet(orig)) && mfr_sibling.is_some() {
            let m = mfr_sibling.as_ref().unwrap();
            map.insert(pdf_name.clone(), serde_json::json!({
                "discovery_source": m.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("manufacturer"),
                "content_origin": m.get("content_origin").and_then(|v| v.as_str()).unwrap_or("manufacturer"),
                "url": m.get("url").and_then(|v| v.as_str()).unwrap_or(""),
                "fetched_at": now, "derived": true, "recovered": true,
                "corrected": "datasheet re-sourced to the manufacturer (was mis-tagged to the KiCad library, which ships no PDFs)",
            }));
            recovered.push(pdf_name);
        }
    }

    // Only fill files that have NO entry at all — so this is idempotent and safe
    // to run on every scan. (A file already recorded "unknown" keeps failing the
    // gate but is not re-written each time.)
    let to_fill: Vec<String> = core_files(mpn).into_iter()
        .filter(|f| dir.join(f).is_file() && !map.contains_key(f.as_str()))
        .collect();
    for f in to_fill {
        // STEP prefers step_source; everything else uses the chain source.
        let src = if f.ends_with(".step") { step_src.clone().or_else(|| chain_src.clone()) }
                  else { chain_src.clone().or_else(|| step_src.clone()) };
        let disc = src.clone().unwrap_or_else(|| "unknown".into());
        if src.is_some() { recovered.push(f.clone()); } else { unknown.push(f.clone()); }
        map.insert(f, serde_json::json!({
            "discovery_source": disc, "content_origin": "", "url": "",
            "fetched_at": now, "derived": true, "recovered": src.is_some(),
        }));
    }
    if !recovered.is_empty() || !unknown.is_empty() {
        std::fs::write(&path, serde_json::to_string_pretty(&serde_json::Value::Object(map))?)?;
    }
    Ok((recovered, unknown))
}

fn looks_like_mpn(s: &str) -> bool {
    let has_alpha = s.chars().any(|c| c.is_ascii_alphabetic());
    let has_digit = s.chars().any(|c| c.is_ascii_digit());
    let len_ok = s.len() >= 4 && s.len() <= 24;
    has_alpha && has_digit && len_ok
}

/// Datasheet PDF quality — the programmatic guarantee that we have a REAL
/// datasheet, not a 1-page denial/cover page. Shells to pdfinfo (page count) and
/// pdftotext (pinout text). Both ship in the container.
pub struct PdfQuality {
    pub pages: u32,
    pub has_pinout: bool,
}

pub fn pdf_quality(file: &Path) -> PdfQuality {
    let pages = std::process::Command::new("pdfinfo").arg(file).output().ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .and_then(|s| s.lines().find_map(|l| l.strip_prefix("Pages:").map(|n| n.trim().parse::<u32>().ok())))
        .flatten()
        .unwrap_or(0);
    // Pinout signal: pin-table / pin-configuration / common analog pin names.
    let text = std::process::Command::new("pdftotext").arg(file).arg("-").output().ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .unwrap_or_default()
        .to_lowercase();
    let has_pinout = ["pin functions", "pin configuration", "terminal functions",
                      "pinout", "pin assignments", "pin descriptions"]
        .iter().any(|k| text.contains(k))
        || text.matches("pin ").count() >= 8;
    PdfQuality { pages, has_pinout }
}

/// Is this chip's datasheet GOOD ENOUGH for ds2sf to extract from? (multi-page +
/// a pinout). Used by the gate + the fetch flow to guarantee a usable datasheet
/// before — and after — running ds2sf. Returns Err(reason) when it isn't.
pub fn datasheet_ready(pdf: &Path) -> std::result::Result<PdfQuality, String> {
    if !pdf.is_file() {
        return Err("no datasheet PDF present".into());
    }
    let q = pdf_quality(pdf);
    if q.pages < 3 {
        return Err(format!("datasheet is only {} page(s) — a CDN cover/denial page, not the real datasheet", q.pages));
    }
    if !q.has_pinout {
        return Err(format!("datasheet ({} pages) has no detectable pinout/pin-table — ds2sf can't extract a symbol from it", q.pages));
    }
    Ok(q)
}

/// Magic-byte validation. PDFs and STEPs both have unambiguous magic
/// strings — checking the first few bytes is the cheapest defense
/// against "naked curl returned a 174 KB Cloudflare denial page that
/// looks like 200 OK." Returns Err if the bytes don't match the claimed
/// extension. KiCad sym/mod and WRL are text-format with looser headers,
/// so we sanity-check just enough to reject HTML.
fn validate_magic_bytes(file: &Path, ext: &str) -> Result<()> {
    use std::io::Read;
    let mut f = std::fs::File::open(file)?;
    let mut buf = [0u8; 64];
    let n = f.read(&mut buf)?;
    let head = &buf[..n];
    let head_lc: String = head.iter().take(32).map(|b| b.to_ascii_lowercase() as char).collect();

    // HTML masquerading as anything is the #1 footgun. Always reject.
    if head_lc.starts_with("<!doctype html") || head_lc.starts_with("<html") || head_lc.contains("<head>") {
        return Err(anyhow!("file looks like HTML (probably a CDN denial / 404 page), not a {ext}: {}", file.display()));
    }

    match ext {
        "pdf" => {
            if !head.starts_with(b"%PDF") {
                return Err(anyhow!("not a real PDF (missing %PDF magic): {}", file.display()));
            }
            // QUALITY GATE (learned the hard way 2026-06-23): %PDF magic alone is
            // NOT enough. A 1-page %PDF is almost always a CDN cover/denial page or
            // a selection guide — NOT the datasheet — and it silently clobbered the
            // real multi-page datasheets, breaking ds2sf. A genuine chip datasheet
            // is many pages. Reject anything < 3 pages outright.
            let q = pdf_quality(file);
            if q.pages > 0 && q.pages < 3 {
                return Err(anyhow!(
                    "PDF has only {} page(s) — not a real datasheet (likely a CDN cover/denial/selection page). \
                     A genuine chip datasheet is many pages with a pinout. Refusing the import: {}",
                    q.pages, file.display()
                ));
            }
        }
        "step" | "stp" => {
            // STEP files start with "ISO-10303-21;" (sometimes preceded by BOM)
            let s: String = head.iter().take(64).map(|b| *b as char).collect();
            if !s.contains("ISO-10303-21") {
                return Err(anyhow!("not a real STEP (missing ISO-10303-21 header): {}", file.display()));
            }
        }
        "kicad_sym" => {
            // KiCad sym is s-expression starting with "(kicad_symbol_lib"
            let s: String = head.iter().take(64).map(|b| *b as char).collect();
            if !s.contains("kicad_symbol_lib") {
                return Err(anyhow!("not a real KiCad symbol library: {}", file.display()));
            }
        }
        "kicad_mod" => {
            let s: String = head.iter().take(64).map(|b| *b as char).collect();
            if !s.contains("footprint") && !s.contains("module") {
                return Err(anyhow!("not a real KiCad footprint: {}", file.display()));
            }
        }
        // VRML and EDA formats — too many vendor variants to magic-check
        // tightly; HTML check above is enough.
        _ => {}
    }
    Ok(())
}

/// Append a structured entry to `<MPN>-fetch-log.json` whenever a real
/// file lands. Audit trail: timestamp, source filename, target name,
/// size, magic-byte check pass. Survives across sessions; the AI doesn't
/// have to remember to write provenance — the binary writes it.
fn append_fetch_log(dest: &Path, mpn: &str, target_name: &str, src: &Path) -> Result<()> {
    use std::time::{SystemTime, UNIX_EPOCH};
    let log_path = dest.join(format!("{mpn}-fetch-log.json"));
    let mut entries: Vec<serde_json::Value> = if log_path.exists() {
        let s = std::fs::read_to_string(&log_path).unwrap_or_default();
        serde_json::from_str(&s).unwrap_or_else(|_| vec![])
    } else {
        vec![]
    };
    let size = std::fs::metadata(src).map(|m| m.len()).unwrap_or(0);
    let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
    entries.push(serde_json::json!({
        "at": now,
        "target": target_name,
        "src_path": src.display().to_string(),
        "size_bytes": size,
        "magic_ok": true, // we wouldn't reach here if validate_magic_bytes failed
    }));
    let _ = std::fs::write(&log_path, serde_json::to_string_pretty(&entries)?);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn infers_mpn_from_typical_ul_filename() {
        let p = Path::new("2099328-Texas-Instruments-TPS62840.zip");
        assert_eq!(infer_mpn(p).as_deref(), Some("TPS62840"));
    }

    #[test]
    fn infers_mpn_from_plain_filename() {
        let p = Path::new("LM358DR.step");
        assert_eq!(infer_mpn(p).as_deref(), Some("LM358DR"));
    }

    #[test]
    fn rejects_non_mpn_tokens() {
        assert!(!looks_like_mpn("Texas"));
        assert!(!looks_like_mpn("2099328"));
        assert!(looks_like_mpn("TPS62840"));
        assert!(looks_like_mpn("LM358"));
    }
}