//! Category B (discrete) `.kicad_sym` generator — Rust port of the Node
//! `generateDiscreteSym`, **with the symbol-resolution bug fixed**.
//!
//! The Node version fetched the whole `<lib>.kicad_sym`, ignored the requested
//! symbol, and keyed every extends/property/header lookup off the FIRST match
//! in the entire file — so every passive came out as `C_Feedthrough`. This port
//! extracts the **specific requested symbol's** block and follows **its own**
//! `(extends …)`, so `Device/R` yields a real 2-pin resistor.
//!
//! The library is fetched via `service-kicad` (`GET /symbols/<lib>.kicad_sym`).
//! No gallia, no Node.

use anyhow::{anyhow, Result};
use regex::Regex;

pub struct DiscreteInput {
    pub symbol_name: String,
    pub manufacturer: String,
    pub package: String,
    pub description: String,
    pub datasheet_url: String,
    pub reference_prefix: Option<String>,
    pub baseline_library: String,
    pub baseline_symbol: String,
    /// (old_number,new_number) and/or (old_name,new_name)
    pub pin_overrides: Vec<PinOverride>,
    /// Short, human on-canvas Value (e.g. "33Ω", "22nF", "Red"). Empty → fall
    /// back to the symbol name (the old behavior). The MPN always survives in the
    /// symbol name + a hidden `MPN` property, so BOM/adom-lbr still get it.
    pub value: String,
    /// Pre-scaled, pre-offset 3D-chip outline polylines in KiCad mm (y-up),
    /// ready to bake beside the symbol shape as thin `(polyline …)` geometry.
    /// Empty → no icon. This is the small (~30%) physical-package thumbnail so
    /// SMD-vs-through-hole and part type read at a glance on the schematic.
    pub outline_polylines: Vec<Vec<(f64, f64)>>,
}

#[derive(Default)]
pub struct PinOverride {
    pub old_number: Option<String>,
    pub new_number: Option<String>,
    pub old_name: Option<String>,
    pub new_name: Option<String>,
}

struct Header {
    hide_pin_names: bool,
    pin_name_offset: String,
}

/// `fetch_lib(library)` returns the raw `<library>.kicad_sym` contents.
pub fn generate_discrete_sym(
    input: &DiscreteInput,
    fetch_lib: &dyn Fn(&str) -> Result<String>,
) -> Result<String> {
    let lib = fetch_lib(&input.baseline_library)?;

    // The requested symbol's OWN top-level block (the fix: not the whole file).
    let baseline = extract_top_symbol(&lib, &input.baseline_symbol)
        .ok_or_else(|| anyhow!("symbol \"{}\" not found in library \"{}\"", input.baseline_symbol, input.baseline_library))?;

    // Follow THIS symbol's extends (if any) to the graphics/pins source.
    let extends = Regex::new(r#"\(extends "([^"]+)"\)"#).unwrap();
    let (graphics_source, graphics_source_name): (String, String) = if let Some(c) = extends.captures(&baseline) {
        let parent = c[1].to_string();
        let parent_block = extract_top_symbol(&lib, &parent)
            .ok_or_else(|| anyhow!("parent symbol \"{}\" not found", parent))?;
        (parent_block, parent)
    } else {
        (baseline.clone(), input.baseline_symbol.clone())
    };

    let graphics_block = extract_sub_symbol(&graphics_source, &graphics_source_name, "_0_1");
    let pins_block = extract_sub_symbol(&graphics_source, &graphics_source_name, "_1_1");
    let header = extract_header(&graphics_source);

    let ref_prefix = match &input.reference_prefix {
        Some(p) if !p.is_empty() => p.clone(),
        _ => extract_property(&graphics_source, "Reference").unwrap_or_else(|| "U".to_string()),
    };

    // Properties come from the requested symbol's block; input overrides win.
    // Value is the SHORT human value (33Ω/22nF/Red) when provided — never the
    // MPN. The MPN stays as the symbol name + a hidden `MPN` property below.
    let prop_value = if input.value.is_empty() {
        input.symbol_name.clone()
    } else {
        input.value.clone()
    };
    let prop_footprint = first_nonempty(&[
        Some(input.package.clone()),
        extract_property(&baseline, "Footprint"),
        extract_property(&graphics_source, "Footprint"),
    ]);
    let prop_datasheet = first_nonempty(&[Some(input.datasheet_url.clone()), extract_property(&baseline, "Datasheet")]);
    let prop_description = first_nonempty(&[Some(input.description.clone()), extract_property(&baseline, "Description")]);
    let prop_manufacturer = first_nonempty(&[Some(input.manufacturer.clone()), extract_property(&baseline, "Manufacturer")]);

    // Clean-discrete mode (a short value or an outline icon was supplied): place
    // Reference + Value at consistent spots CLEAR of the body — Reference
    // upper-right, Value lower-right, left-justified — instead of the KiCad
    // baseline's `(0 0 90)` Value that sits on top of the shape. Zero overlap,
    // identical placement on every discrete.
    let clean = !input.value.is_empty() || !input.outline_polylines.is_empty();
    let (ref_at, val_at, ref_effects, val_effects) = if clean {
        (
            "2.54 1.0 0".to_string(),
            "2.54 -1.0 0".to_string(),
            "(effects (font (size 1.27 1.27)) (justify left))".to_string(),
            "(effects (font (size 1.27 1.27)) (justify left))".to_string(),
        )
    } else {
        (
            extract_property_at(&graphics_source, "Reference").unwrap_or_else(|| "0 2.54 0".to_string()),
            extract_property_at(&graphics_source, "Value").unwrap_or_else(|| "0 -2.54 0".to_string()),
            extract_property_effects(&graphics_source, "Reference").unwrap_or_else(|| "(effects (font (size 1.27 1.27)))".to_string()),
            extract_property_effects(&graphics_source, "Value").unwrap_or_else(|| "(effects (font (size 1.27 1.27)))".to_string()),
        )
    };

    // Rename the sub-symbol blocks to the new symbol name.
    let mut renamed_graphics = graphics_block.replacen(
        &format!("(symbol \"{}_0_1\"", graphics_source_name),
        &format!("(symbol \"{}_0_1\"", input.symbol_name),
        1,
    );
    let mut renamed_pins = pins_block.replacen(
        &format!("(symbol \"{}_1_1\"", graphics_source_name),
        &format!("(symbol \"{}_1_1\"", input.symbol_name),
        1,
    );

    // Apply pin overrides.
    for ov in &input.pin_overrides {
        if let (Some(o), Some(n)) = (&ov.old_number, &ov.new_number) {
            let re = Regex::new(&format!(r#"\(number "{}""#, esc_regex(o))).unwrap();
            renamed_pins = re.replace_all(&renamed_pins, format!("(number \"{}\"", n).as_str()).into_owned();
        }
        if let (Some(o), Some(n)) = (&ov.old_name, &ov.new_name) {
            let re = Regex::new(&format!(r#"\(name "{}""#, esc_regex(o))).unwrap();
            renamed_pins = re.replace_all(&renamed_pins, format!("(name \"{}\"", n).as_str()).into_owned();
        }
    }
    // (renamed_graphics is not mutated after this point; bind to silence warning)
    let _ = &mut renamed_graphics;

    let mut lines: Vec<String> = Vec::new();
    lines.push("(kicad_symbol_lib".to_string());
    lines.push("  (version 20231120)".to_string());
    lines.push("  (generator \"adom\")".to_string());
    lines.push(format!("  (symbol \"{}\"", input.symbol_name));

    if header.hide_pin_names {
        lines.push("    (pin_numbers (hide yes))".to_string());
        lines.push(format!("    (pin_names (offset {}) (hide yes))", header.pin_name_offset));
    } else {
        lines.push(format!("    (pin_names (offset {}))", header.pin_name_offset));
    }
    lines.push("    (exclude_from_sim no)".to_string());
    lines.push("    (in_bom yes)".to_string());
    lines.push("    (on_board yes)".to_string());

    lines.push(format!("    (property \"Reference\" \"{}\"", ref_prefix));
    lines.push(format!("      (at {})", ref_at));
    lines.push(format!("      {}", ref_effects));
    lines.push("    )".to_string());
    lines.push(format!("    (property \"Value\" \"{}\"", prop_value));
    lines.push(format!("      (at {})", val_at));
    lines.push(format!("      {}", val_effects));
    lines.push("    )".to_string());
    lines.push(format!("    (property \"Footprint\" \"{}\"", prop_footprint));
    lines.push("      (at 0 0 0)".to_string());
    lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());
    lines.push("    )".to_string());
    lines.push(format!("    (property \"Datasheet\" \"{}\"", prop_datasheet));
    lines.push("      (at 0 0 0)".to_string());
    lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());
    lines.push("    )".to_string());
    lines.push(format!("    (property \"Description\" \"{}\"", prop_description));
    lines.push("      (at 0 0 0)".to_string());
    lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());
    lines.push("    )".to_string());
    if !prop_manufacturer.is_empty() {
        lines.push(format!("    (property \"Manufacturer\" \"{}\"", prop_manufacturer));
        lines.push("      (at 0 0 0)".to_string());
        lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());
        lines.push("    )".to_string());
    }
    // Keep the full MPN discoverable (BOM / adom-lbr) even though the on-canvas
    // Value is now the short human value.
    lines.push(format!("    (property \"MPN\" \"{}\"", input.symbol_name));
    lines.push("      (at 0 0 0)".to_string());
    lines.push("      (effects (font (size 1.27 1.27)) (hide yes))".to_string());
    lines.push("    )".to_string());

    // Bake the small offset 3D-chip outline into the _0_1 graphics sub-symbol as
    // real thin polyline geometry (survives to KiCad + adom-lbr — not an <image>).
    if !input.outline_polylines.is_empty() {
        renamed_graphics = inject_outline(&renamed_graphics, &input.outline_polylines);
    }

    lines.push(renamed_graphics);
    lines.push(renamed_pins);
    lines.push("  )".to_string());
    lines.push(")".to_string());
    lines.push(String::new());

    Ok(lines.join("\n"))
}

/// Insert the outline polylines into the `(symbol "..._0_1" …)` graphics block,
/// just before its closing paren, so KiCad renders them alongside the shape.
fn inject_outline(graphics: &str, polys: &[Vec<(f64, f64)>]) -> String {
    let mut inject = String::new();
    for pl in polys {
        if pl.len() < 2 {
            continue;
        }
        let pts: String = pl
            .iter()
            .map(|(x, y)| format!("(xy {:.3} {:.3})", x, y))
            .collect::<Vec<_>>()
            .join(" ");
        inject.push_str(&format!(
            "\n      (polyline (pts {}) (stroke (width 0.12) (type default)) (fill (type none)))",
            pts
        ));
    }
    if inject.is_empty() {
        return graphics.to_string();
    }
    match graphics.rfind(')') {
        Some(idx) => format!("{}{}\n     )", graphics[..idx].trim_end(), inject),
        None => graphics.to_string(),
    }
}

fn first_nonempty(opts: &[Option<String>]) -> String {
    for o in opts {
        if let Some(s) = o {
            if !s.is_empty() {
                return s.clone();
            }
        }
    }
    String::new()
}

/// Extract a top-level `(symbol "NAME" …)` block by exact-quote match + paren balance.
fn extract_top_symbol(lib: &str, name: &str) -> Option<String> {
    let marker = format!("(symbol \"{}\"", name);
    let start = lib.find(&marker)?;
    let bytes = lib.as_bytes();
    let mut depth = 0i32;
    let mut end = start;
    for i in start..lib.len() {
        match bytes[i] {
            b'(' => depth += 1,
            b')' => {
                depth -= 1;
                if depth == 0 {
                    end = i;
                    break;
                }
            }
            _ => {}
        }
    }
    Some(lib[start..=end].to_string())
}

/// Extract a `(symbol "NAME<suffix>" …)` sub-block, preserving the leading
/// indentation of its line (matches Node `extractSubSymbol`).
fn extract_sub_symbol(content: &str, name: &str, suffix: &str) -> String {
    let target = format!("(symbol \"{}{}\"", name, suffix);
    let start = match content.find(&target) {
        Some(s) => s,
        None => return String::new(),
    };
    let bytes = content.as_bytes();
    let mut depth = 0i32;
    let mut end = start;
    for i in start..content.len() {
        match bytes[i] {
            b'(' => depth += 1,
            b')' => {
                depth -= 1;
                if depth == 0 {
                    end = i;
                    break;
                }
            }
            _ => {}
        }
    }
    let line_start = content[..start].rfind('\n').map(|p| p + 1).unwrap_or(0);
    content[line_start..=end].to_string()
}

fn extract_header(content: &str) -> Header {
    let hide_pin_names = Regex::new(r"(?s)\(pin_names.*?\(hide\s+yes\)").unwrap().is_match(content);
    let offset = Regex::new(r"\(pin_names\s*\n?\s*\(offset\s+([\d.]+)\)")
        .unwrap()
        .captures(content)
        .map(|c| c[1].to_string())
        .unwrap_or_else(|| "1.016".to_string());
    Header { hide_pin_names, pin_name_offset: offset }
}

fn extract_property(content: &str, prop: &str) -> Option<String> {
    let re = Regex::new(&format!(r#"\(property "{}" "([^"]*)""#, esc_regex(prop))).unwrap();
    re.captures(content).map(|c| c[1].to_string())
}

fn extract_property_at(content: &str, prop: &str) -> Option<String> {
    let re = Regex::new(&format!(r#"\(property "{}" "[^"]*"\s*\n\s*\(at\s+([^)]+)\)"#, esc_regex(prop))).unwrap();
    re.captures(content).map(|c| c[1].trim().to_string())
}

fn extract_property_effects(content: &str, prop: &str) -> Option<String> {
    let prop_start = content.find(&format!("(property \"{}\"", prop))?;
    let effects_rel = content[prop_start..].find("(effects")?;
    let effects_start = prop_start + effects_rel;
    let prop_end = find_matching_paren(content, prop_start);
    if effects_start > prop_end {
        return None;
    }
    let effects_end = find_matching_paren(content, effects_start);
    Some(content[effects_start..=effects_end].to_string())
}

fn find_matching_paren(content: &str, start: usize) -> usize {
    let bytes = content.as_bytes();
    let mut depth = 0i32;
    for i in start..content.len() {
        match bytes[i] {
            b'(' => depth += 1,
            b')' => {
                depth -= 1;
                if depth == 0 {
                    return i;
                }
            }
            _ => {}
        }
    }
    content.len().saturating_sub(1)
}

fn esc_regex(s: &str) -> String {
    regex::escape(s)
}

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

    fn fixture_fetch(_lib: &str) -> Result<String> {
        Ok(include_str!("../tests/golden/Device.kicad_sym").to_string())
    }

    #[test]
    fn discrete_r_is_a_real_resistor_not_feedthrough() {
        let input = DiscreteInput {
            symbol_name: "R-TEST-10K".into(),
            manufacturer: "Yageo".into(),
            package: "R_0402_1005Metric".into(),
            description: "10k 1% 0402".into(),
            datasheet_url: "https://yageo/r.pdf".into(),
            reference_prefix: Some("R".into()),
            baseline_library: "Device".into(),
            baseline_symbol: "R".into(),
            pin_overrides: vec![],
            value: String::new(),
            outline_polylines: vec![],
        };
        let out = generate_discrete_sym(&input, &fixture_fetch).unwrap();

        // The bug fix: a resistor has exactly 2 pins (the broken Node path gave 3 = C_Feedthrough).
        assert_eq!(out.matches("(pin ").count(), 2, "resistor must have 2 pins, not C_Feedthrough's 3");
        assert!(out.contains("(symbol \"R-TEST-10K_0_1\""), "renamed graphics sub-symbol");
        assert!(out.contains("(symbol \"R-TEST-10K_1_1\""), "renamed pins sub-symbol");
        assert!(out.contains("(property \"Reference\" \"R\""), "reference prefix R");
        // value empty → falls back to the symbol name (MPN)
        assert!(out.contains("(property \"Value\" \"R-TEST-10K\""));
        assert!(out.contains("(property \"Footprint\" \"R_0402_1005Metric\""));
        assert!(!out.contains("C_Feedthrough"), "must not leak the C_Feedthrough baseline");
        assert!(out.starts_with("(kicad_symbol_lib"));
    }

    #[test]
    fn short_value_and_outline_are_applied() {
        let input = DiscreteInput {
            symbol_name: "0603WAF330JT5E".into(),
            manufacturer: "UNI-ROYAL".into(),
            package: "R_0603_1608Metric".into(),
            description: "33Ω 1% 0603".into(),
            datasheet_url: "".into(),
            reference_prefix: Some("R".into()),
            baseline_library: "Device".into(),
            baseline_symbol: "R".into(),
            pin_overrides: vec![],
            value: "33Ω".into(),
            outline_polylines: vec![vec![(-4.0, 1.0), (-3.0, 1.0), (-3.0, -1.0)]],
        };
        let out = generate_discrete_sym(&input, &fixture_fetch).unwrap();
        // short human value on canvas, MPN preserved in a hidden property
        assert!(out.contains("(property \"Value\" \"33Ω\""), "on-canvas value is short");
        assert!(!out.contains("(property \"Value\" \"0603WAF330JT5E\""), "MPN not the value");
        assert!(out.contains("(property \"MPN\" \"0603WAF330JT5E\""), "MPN preserved hidden");
        // the offset 3D-chip outline is baked as real polyline geometry
        assert!(out.contains("(polyline (pts (xy -4.000 1.000)"), "outline baked into graphics");
    }
}