//! Filesystem walk for atom discovery + path-safety primitives.
//!
//! `discover_atoms` enumerates `<root>/*/atoms/*.md` with `follow_links(false)`.
//! `safe_join` is the authoritative base+rel path-join — rejects absolute
//! components and `..`, canonicalises, asserts base containment.

use crate::error::Error;
use crate::frontmatter::{
    parse_frontmatter, parse_side_effects, AtomKind, AtomMeta, Frontmatter,
};
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use walkdir::WalkDir;

/// Walk `<root>/*/atoms/*.md`. Skip-on-invalid: malformed files emit a
/// stderr warning and are dropped. Never follows symlinks.
pub fn discover_atoms(root: &Path) -> Vec<AtomMeta> {
    let mut out = Vec::new();
    for entry in WalkDir::new(root)
        .max_depth(3)
        .follow_links(false)
        .into_iter()
        .flatten()
    {
        if !is_atom_md(entry.path()) {
            continue;
        }
        match parse_one(entry.path()) {
            Ok(meta) => out.push(meta),
            Err(e) => eprintln!("warn: skip {}: {}", entry.path().display(), e),
        }
    }
    out
}

fn is_atom_md(path: &Path) -> bool {
    path.is_file()
        && path.extension().and_then(|s| s.to_str()) == Some("md")
        && path
            .parent()
            .and_then(|p| p.file_name())
            .is_some_and(|n| n == "atoms")
}

fn parse_one(md_path: &Path) -> Result<AtomMeta, Error> {
    let text = std::fs::read_to_string(md_path)?;
    let (fm_text, body) = parse_frontmatter(&text)?;
    let fm: Frontmatter = serde_yaml_ng::from_str(fm_text)?;
    build_meta(fm, body, md_path)
}

fn build_meta(fm: Frontmatter, body: &str, md_path: &Path) -> Result<AtomMeta, Error> {
    let kind = AtomKind::from_str(&fm.kind)?;
    let (crate_name, verb) = split_atom_id(&fm.atom)?;
    let md_dir = md_path.parent().unwrap_or(md_path);
    let input_schema = resolve_opt_schema(md_dir, fm.input.as_ref().and_then(|s| s.schema.as_deref()));
    let output_schema =
        resolve_opt_schema(md_dir, fm.output.as_ref().and_then(|s| s.schema.as_deref()));
    Ok(AtomMeta {
        full_id: fm.atom.clone(),
        crate_name,
        verb,
        kind,
        version: fm.version.unwrap_or_default(),
        md_path: md_path.to_path_buf(),
        input_schema,
        output_schema,
        side_effects: parse_side_effects(&fm.side_effects),
        idempotent: fm.idempotent.unwrap_or(false),
        stability: fm.stability.unwrap_or_else(|| "unknown".into()),
        keywords: fm.keywords,
        related: fm.related,
        body: body.to_string(),
        taxonomy: fm.taxonomy,
        lineage: fm.lineage,
    })
}

/// Resolve an optional schema path relative to the atom's directory.
/// Silently drops entries that fail `safe_join` — lint catches them separately.
fn resolve_opt_schema(md_dir: &Path, rel: Option<&str>) -> Option<PathBuf> {
    rel.and_then(|r| safe_join(md_dir, r).ok())
}

/// Split `<crate>::<verb>` atom id into components.
pub fn split_atom_id(id: &str) -> Result<(String, String), Error> {
    match id.split_once("::") {
        Some((c, v)) if !c.is_empty() && !v.is_empty() => Ok((c.into(), v.into())),
        _ => Err(Error::BadAtomId(id.to_string())),
    }
}

/// Parse a single wikilink `[[target]]`. Returns `None` if not a wikilink,
/// empty, or if the inner body contains a stray bracket (e.g. `[[[foo]]`).
pub fn parse_wikilink(raw: &str) -> Option<String> {
    let t = raw.trim();
    let inner = t.strip_prefix("[[").and_then(|s| s.strip_suffix("]]"))?;
    let inner = inner.trim();
    if inner.is_empty() || inner.contains('[') || inner.contains(']') {
        None
    } else {
        Some(inner.to_string())
    }
}

/// Heuristic atom-target filter: `<crate>::<verb>` looks like an atom,
/// everything starting with `rules/` or `rule ` is a rule reference.
pub fn is_atom_target(target: &str) -> bool {
    !target.starts_with("rules/") && !target.starts_with("rule ")
}

/// Classified wikilink target — atom, rule reference, or other (notes etc.).
///
/// `Rule(slug)` strips the `rules/` prefix and drops any optional `RULE `
/// token, leaving a caller-friendly slug (`"0.12"`, `"memory-protocol"`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WikilinkTarget {
    Atom(String),
    Rule(String),
    Other(String),
}

/// Classify a wikilink inner body. `inner` is the already-unwrapped target
/// (no `[[ ]]`). Use this on the output of `parse_wikilink`.
pub fn classify_wikilink(inner: &str) -> WikilinkTarget {
    let t = inner.trim();
    if let Some(rest) = t.strip_prefix("rules/") {
        return WikilinkTarget::Rule(normalize_rule_slug(rest));
    }
    if let Some(rest) = t.strip_prefix("rule ") {
        return WikilinkTarget::Rule(normalize_rule_slug(rest));
    }
    if is_atom_target(t) && t.contains("::") {
        WikilinkTarget::Atom(t.to_string())
    } else {
        WikilinkTarget::Other(t.to_string())
    }
}

/// Normalise the tail after `rules/` or `rule ` into a short slug.
/// `"RULE 0.12"` → `"0.12"`, `"memory-protocol"` → `"memory-protocol"`.
fn normalize_rule_slug(rest: &str) -> String {
    let r = rest.trim();
    if let Some(tail) = r.strip_prefix("RULE ") {
        return tail.trim().to_string();
    }
    r.to_string()
}

/// Safe base+rel path join. Rejects absolute paths, parent (`..`) components,
/// and post-canonicalise escapes from `base`.
pub fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, Error> {
    let rel_path = Path::new(rel);
    if rel_path.is_absolute() {
        return Err(Error::PathAbsolute(rel.to_string()));
    }
    for comp in rel_path.components() {
        if matches!(comp, Component::ParentDir) {
            return Err(Error::PathParent(rel.to_string()));
        }
    }
    let joined = base.join(rel_path);
    // Canonicalise lazily — if either path doesn't exist yet, fall back to
    // the lexical check we already did (absolute + parent-free is enough).
    let base_canon = base.canonicalize().ok();
    let joined_canon = joined.canonicalize().ok();
    if let (Some(bc), Some(jc)) = (base_canon, joined_canon) {
        if !jc.starts_with(&bc) {
            return Err(Error::PathEscape {
                base: bc,
                rel: rel.to_string(),
            });
        }
    }
    Ok(joined)
}
