//! KiCad-style s-expression reader — the shared parser for every `.kicad_sym`
//! / `.kicad_mod` consumer in this workspace (replaces the old regex parsing).
//!
//! Modeled on KiCad's own parser pair (DSNLEXER + SCH_IO_KICAD_SEXPR_PARSER in
//! the KiCad repo), from which it borrows three design points:
//!  1. a line-tracking lexer, so every parse error cites its line
//!     (KiCad: `THROW_PARSE_ERROR(..., CurLineNumber(), CurOffset())`);
//!  2. strict structure enforcement — unbalanced parens, unterminated strings
//!     and trailing garbage are ERRORS, never a silently-partial tree
//!     (KiCad: `Expecting(T_LEFT)` / `NeedRIGHT()` discipline);
//!  3. the format-version gate — semantics key off the file's declared
//!     `(version N)` (KiCad: `m_requiredVersion`), e.g. `~` means "empty
//!     string" only in files older than 20250318.

use anyhow::{bail, Result};

/// A parsed s-expression node. Atoms carry their text with string quoting
/// already resolved (`"a b"` and `a_b` both become plain atoms; a `\u{0}`
/// marker is NOT leaked — quoted-ness is dropped after parse).
#[derive(Debug)]
pub enum Sx {
    Atom(String),
    List(Vec<Sx>),
}

impl Sx {
    pub fn head(&self) -> Option<&str> {
        match self {
            Sx::List(v) => match v.first() {
                Some(Sx::Atom(a)) => Some(a.as_str()),
                _ => None,
            },
            _ => None,
        }
    }
    pub fn items(&self) -> &[Sx] {
        match self {
            Sx::List(v) => v,
            _ => &[],
        }
    }
    /// First child list whose head == name.
    pub fn child(&self, name: &str) -> Option<&Sx> {
        self.items().iter().find(|s| s.head() == Some(name))
    }
    /// Every direct child list whose head == name (non-recursive).
    pub fn children<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Sx> + 'a {
        self.items().iter().filter(move |s| s.head() == Some(name))
    }
    /// Atom string at position i (after the head), if it's an atom.
    pub fn atom_at(&self, i: usize) -> Option<&str> {
        match self.items().get(i) {
            Some(Sx::Atom(a)) => Some(a.as_str()),
            _ => None,
        }
    }
    /// Numeric atoms in this list (skipping the head atom).
    pub fn nums(&self) -> Vec<f64> {
        self.items()
            .iter()
            .skip(1)
            .filter_map(|s| match s {
                Sx::Atom(a) => a.parse::<f64>().ok(),
                _ => None,
            })
            .collect()
    }
    /// Walk the whole tree, collecting every list whose head == name.
    pub fn collect<'a>(&'a self, name: &str, out: &mut Vec<&'a Sx>) {
        if self.head() == Some(name) {
            out.push(self);
        }
        for s in self.items() {
            if let Sx::List(_) = s {
                s.collect(name, out);
            }
        }
    }
    /// The document's declared `(version N)`, if present — the equivalent of
    /// KiCad's `m_requiredVersion` gate. Callers use this for version-scoped
    /// semantics (e.g. `~` means empty only when `version < 20250318`).
    pub fn doc_version(&self) -> Option<u32> {
        self.child("version")
            .and_then(|v| v.atom_at(1))
            .and_then(|s| s.parse().ok())
    }
}

/// A property/field string under the document's version semantics: before
/// format 20250318 a bare `~` meant the empty string (KiCad parser:
/// `if( m_requiredVersion < 20250318 && CurStr() == "~" )`).
pub fn resolve_tilde(s: &str, doc_version: Option<u32>) -> String {
    if s == "~" && doc_version.unwrap_or(0) < 20250318 {
        String::new()
    } else {
        s.to_string()
    }
}

#[derive(Debug)]
enum Tok {
    Open,
    Close,
    Atom(String),
}

/// Tokenize with line tracking. Errors: unterminated string.
fn tokenize(text: &str) -> Result<Vec<(Tok, u32)>> {
    let mut toks = Vec::new();
    let mut it = text.chars().peekable();
    let mut line: u32 = 1;
    while let Some(&c) = it.peek() {
        if c == '\n' {
            line += 1;
            it.next();
        } else if c.is_whitespace() {
            it.next();
        } else if c == '(' {
            toks.push((Tok::Open, line));
            it.next();
        } else if c == ')' {
            toks.push((Tok::Close, line));
            it.next();
        } else if c == '"' {
            let start = line;
            it.next();
            let mut buf = String::new();
            let mut closed = false;
            while let Some(&c) = it.peek() {
                match c {
                    '"' => {
                        it.next();
                        closed = true;
                        break;
                    }
                    '\\' => {
                        it.next();
                        if let Some(&e) = it.peek() {
                            // resolve the standard escapes; pass others through
                            buf.push(match e {
                                'n' => '\n',
                                't' => '\t',
                                other => other,
                            });
                            if e == '\n' {
                                line += 1;
                            }
                            it.next();
                        }
                    }
                    _ => {
                        if c == '\n' {
                            line += 1;
                        }
                        buf.push(c);
                        it.next();
                    }
                }
            }
            if !closed {
                bail!("unterminated string starting at line {start}");
            }
            toks.push((Tok::Atom(buf), start));
        } else {
            let mut buf = String::new();
            while let Some(&c) = it.peek() {
                if c.is_whitespace() || c == '(' || c == ')' {
                    break;
                }
                buf.push(c);
                it.next();
            }
            toks.push((Tok::Atom(buf), line));
        }
    }
    Ok(toks)
}

/// Parse one list starting at toks[*pos] (which must be Open). Strict: a
/// missing close paren is an error, not an implicit EOF-close.
fn parse_list(toks: &[(Tok, u32)], pos: &mut usize) -> Result<Sx> {
    let open_line = toks[*pos].1;
    *pos += 1; // consume '('
    let mut list = Vec::new();
    loop {
        match toks.get(*pos) {
            Some((Tok::Open, _)) => list.push(parse_list(toks, pos)?),
            Some((Tok::Close, _)) => {
                *pos += 1;
                return Ok(Sx::List(list));
            }
            Some((Tok::Atom(a), _)) => {
                list.push(Sx::Atom(a.clone()));
                *pos += 1;
            }
            None => bail!("unbalanced s-expression: '(' at line {open_line} is never closed"),
        }
    }
}

/// Parse a document into its single root list. Strict about structure
/// (unbalanced/unterminated input errors with a line number); tolerant about
/// leading junk before the first '(' (some exporters emit a BOM/comment).
pub fn parse_root(text: &str) -> Result<Sx> {
    let toks = tokenize(text)?;
    let Some(start) = toks.iter().position(|(t, _)| matches!(t, Tok::Open)) else {
        bail!("no s-expression found");
    };
    let mut pos = start;
    let root = parse_list(&toks, &mut pos)?;
    // Anything after the root except stray whitespace is a malformed file —
    // report it instead of silently ignoring half the document.
    if let Some((tok, line)) = toks.get(pos) {
        let what = match tok {
            Tok::Open => "'('".to_string(),
            Tok::Close => "')'".to_string(),
            Tok::Atom(a) => format!("{a:?}"),
        };
        bail!("trailing content after the root s-expression: {what} at line {line}");
    }
    Ok(root)
}

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

    #[test]
    fn strict_errors_carry_lines() {
        let e = parse_root("(a\n(b c)\n").unwrap_err().to_string();
        assert!(e.contains("line 1"), "unclosed root cites its open line: {e}");
        let e = parse_root("(a \"unterminated\n").unwrap_err().to_string();
        assert!(e.contains("line 1"), "unterminated string cites its line: {e}");
        let e = parse_root("(a) (b)").unwrap_err().to_string();
        assert!(e.contains("trailing"), "second root is trailing content: {e}");
    }

    #[test]
    fn tilde_gate_matches_kicad() {
        assert_eq!(resolve_tilde("~", Some(20211014)), "");
        assert_eq!(resolve_tilde("~", Some(20231120)), "");
        assert_eq!(resolve_tilde("~", Some(20251024)), "~"); // literal in new files
        assert_eq!(resolve_tilde("~", None), ""); // no version → legacy file
        assert_eq!(resolve_tilde("A~B", Some(20211014)), "A~B");
    }

    #[test]
    fn doc_version_reads_header() {
        let r = parse_root("(kicad_symbol_lib (version 20231120) (generator \"x\"))").unwrap();
        assert_eq!(r.doc_version(), Some(20231120));
    }
}