use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

use crate::consts::EXT_TEMPLATE;
use crate::depgraph::DepGraph;
use crate::engine::cache::{self, Caches};
use crate::engine::parse::parse_directive;
use crate::engine::substitute::render_with_resolver;
use crate::engine::types::{Directive, SnippetDirective, SnippetMode};
use crate::fsutil::{copy_tree, ensure_parent, join_content_file, join_snippet_file, path_no_ext};

/* ---------- helpers ---------- */

fn canonicalize_if_exists(p: PathBuf) -> PathBuf {
    std::fs::canonicalize(&p).unwrap_or(p)
}

fn project_prefix(vars: &HashMap<String, String>, prefix: &str) -> HashMap<String, String> {
    let mut out = HashMap::new();
    let pre = format!("{prefix}.");
    for (k, v) in vars {
        if k == prefix {
            out.insert("value".to_string(), v.clone());
        } else if k.starts_with(&pre) {
            out.insert(k[pre.len()..].to_string(), v.clone());
        }
    }
    out
}

/// "file/key" → Some(("file","key")), "key" → None
fn split_ns(key: &str) -> Option<(&str, &str)> {
    let (first, second) = key.split_once('/')?;
    if first.is_empty() || second.is_empty() { return None; }
    Some((first, second))
}

/* ---------- small collector to avoid `unsafe` ---------- */

struct PtrDeps<'a> {
    used_content_ptrs: &'a mut Vec<String>,
}

impl<'a> PtrDeps<'a> {
    fn add(&mut self, key: impl Into<String>) {
        let key = key.into();
        if !self.used_content_ptrs.contains(&key) {
            self.used_content_ptrs.push(key);
        }
    }
}

/* ---------- Engine ---------- */

pub struct Engine {
    pub src_dir: PathBuf,
    pub out_dir: PathBuf,
    pub snippets_dir: PathBuf,
    pub content_dir: PathBuf,
    pub static_dir: PathBuf,
    pub verbose: bool,
    pub depgraph: DepGraph,
    pub caches: Caches, // public so watch.rs can peek
}

impl Engine {
    /// Process conditional comment blocks in snippet HTML using presence-only flags.
    /// Supports: `<!-- if flag --> ... <!-- else --> ... <!-- endif -->`
    /// Nested conditionals inside the chosen branch are processed recursively.
    fn process_conditionals(&self, input: &str, flags: &std::collections::HashSet<String>) -> String {
        let bytes = input.as_bytes();
        let mut out = String::with_capacity(input.len());
        let mut i = 0usize;
        let mut last_emit = 0usize;

        while i + 4 <= bytes.len() {
            if &bytes[i..i + 4] == b"<!--" {
                if last_emit < i { out.push_str(&input[last_emit..i]); }
                // find end of current comment
                let mut end = i + 4;
                while end + 3 <= bytes.len() && &bytes[end..end + 3] != b"-->" { end += 1; }
                if end + 3 > bytes.len() { break; }
                let inner = input[i + 4..end].trim();
                // check for `if <flag>` (robust to multiple spaces/tabs/newlines)
                if inner.starts_with("if ") || inner.starts_with("if\t") || inner.starts_with("if\n") {
                    let cond_key = inner.split_whitespace().nth(1).unwrap_or("");
                    // scan forward to find optional else and required endif, tracking nesting depth
                    let mut j = end + 3; // after `if` comment
                    let mut else_pos: Option<(usize, usize)> = None; // (start, end)
                    let mut endif_pos: Option<(usize, usize)> = None; // (start, end)
                    let mut depth = 0usize;
                    while j + 4 <= bytes.len() {
                        if &bytes[j..j + 4] == b"<!--" {
                            let mut cend = j + 4;
                            while cend + 3 <= bytes.len() && &bytes[cend..cend + 3] != b"-->" { cend += 1; }
                            if cend + 3 > bytes.len() { break; }
                            let cin = input[j + 4..cend].trim();
                            if cin.starts_with("if ") || cin.starts_with("if\t") || cin.starts_with("if\n") {
                                depth += 1;
                            } else if cin == "endif" {
                                if depth == 0 { endif_pos = Some((j, cend + 3)); break; }
                                depth -= 1;
                            } else if cin == "else" && depth == 0 && else_pos.is_none() {
                                else_pos = Some((j, cend + 3));
                            }
                            j = cend + 3;
                            continue;
                        }
                        j += 1;
                    }
                    if let Some((endif_start, endif_end)) = endif_pos {
                        let (true_start, true_end, false_start, false_end) = if let Some((else_start, else_end)) = else_pos {
                            (end + 3, else_start, else_end, endif_start)
                        } else {
                            (end + 3, endif_start, endif_start, endif_start)
                        };
                        let truthy = flags.contains(cond_key);
                        let chosen = if truthy {
                            &input[true_start..true_end]
                        } else {
                            &input[false_start..false_end]
                        };
                        let processed = self.process_conditionals(chosen, flags);
                        out.push_str(&processed);
                        i = endif_end;
                        last_emit = i;
                        continue;
                    } else {
                        // malformed: no endif → emit raw
                        out.push_str(&input[i..end + 3]);
                        i = end + 3;
                        last_emit = i;
                        continue;
                    }
                }
                // not a conditional → emit as-is
                out.push_str(&input[i..end + 3]);
                i = end + 3;
                last_emit = i;
                continue;
            }
            i += 1;
        }
        if last_emit < input.len() {
            out.push_str(&input[last_emit..]);
        }
        out
    }
    pub fn new(
        src_dir: PathBuf,
        out_dir: PathBuf,
        snippets_dir: PathBuf,
        content_dir: PathBuf,
        static_dir: PathBuf,
        verbose: bool,
    ) -> Result<Self> {
        Ok(Self {
            src_dir: canonicalize_if_exists(src_dir),
            out_dir: canonicalize_if_exists(out_dir),
            snippets_dir: canonicalize_if_exists(snippets_dir),
            content_dir: canonicalize_if_exists(content_dir),
            static_dir: canonicalize_if_exists(static_dir),
            verbose,
            depgraph: DepGraph::default(),
            caches: Caches::new(),
        })
    }

    pub fn full_build(&mut self) -> Result<()> {
        fs::create_dir_all(&self.out_dir)?;
        if self.static_dir.exists() {
            copy_tree(&self.static_dir, &self.out_dir, self.verbose)?;
        }
        for entry in WalkDir::new(&self.src_dir) {
            let entry = entry?;
            let p = entry.path();
            if entry.file_type().is_file()
                && p.extension().and_then(|s| s.to_str()).unwrap_or("") == EXT_TEMPLATE
            {
                self.build_template(p)?;
            }
        }
        Ok(())
    }

    /// Build a single template; records snippet deps, content file deps, and content **pointer** deps.
    pub fn build_template(&mut self, template_path: &Path) -> Result<()> {
        let input = fs::read_to_string(template_path)
            .with_context(|| format!("read {}", template_path.display()))?;

        let rel_tpl = template_path
            .strip_prefix(&self.src_dir)
            .map_err(|_| anyhow::anyhow!(
                "template not under src_dir: {} (src_dir: {})",
                template_path.display(),
                self.src_dir.display()
            ))?
            .to_string_lossy()
            .replace('\\', "/");
        let tpl_key = rel_tpl.trim_end_matches(".htmlt"); // for content/<tpl_key>.toml

        // deps to collect
        let mut used_snippet_files: Vec<String> = Vec::new();
        let mut used_content_files: Vec<String> = Vec::new();
        let mut used_content_ptrs: Vec<String> = Vec::new();

        // template companion content (pointer-precise: do NOT push file-level dep)
        let (tpl_vars_file, tpl_vars_rel) = self.resolve_content_file(tpl_key);
        let mut tpl_vars = HashMap::new();
        let mut tpl_vars_origin: Option<String> = None;
        if tpl_vars_file.exists() {
            let was_cached = self.caches.vars.contains_key(&tpl_vars_rel);
            match cache::load_vars(&mut self.caches, &tpl_vars_rel, &tpl_vars_file) {
                Ok(m) => {
                    if !was_cached { log::debug!("→ [content] {} ({} keys)", tpl_vars_file.display(), m.len()); }
                    tpl_vars = m;
                    tpl_vars_origin = Some(tpl_vars_rel.clone()); // record pointer deps when a tpl var is read
                }
                Err(e) => {
                    if !was_cached { log::warn!("→ [content] parse error: {} - {}", tpl_vars_file.display(), e); }
                }
            }
            // NOTE: do NOT push file-level used_content_files here — pointer-only
        }

        // render recursively
        let mut stack = HashSet::<String>::new();
        let rendered = self.render_buffer(
            &input,
            tpl_key,
            &tpl_vars,
            tpl_vars_origin.as_deref(),
            &mut used_snippet_files,
            &mut used_content_files,
            &mut used_content_ptrs,
            &mut stack,
        )?;

        // write out
        let mut out_path = self.out_dir.join(&rel_tpl);
        out_path.set_extension("html");
        ensure_parent(&out_path)?;
        fs::write(&out_path, rendered)?;

        // record deps
        self.depgraph.record(
            rel_tpl.clone(),
            used_snippet_files,
            used_content_files,
            used_content_ptrs,
        );
        log::info!("[build] {}", out_path.display());
        Ok(())
    }

    /// Render a string buffer:
    /// - substitute placeholders
    /// - replace `<!-- @snippet ... -->` nodes recursively
    fn render_buffer(
        &mut self,
        input: &str,
        tpl_key: &str,
        default_vars: &HashMap<String, String>,
        default_vars_origin: Option<&str>, // where default vars came from (for pointer deps)
        used_snippets: &mut Vec<String>,
        used_contents: &mut Vec<String>,
        used_content_ptrs: &mut Vec<String>,
        stack: &mut HashSet<String>,
    ) -> Result<String> {
        let mut out = String::with_capacity(input.len() + 256);
        let bytes = input.as_bytes();
        let mut i = 0usize;
        let mut last_emit = 0usize;

        while i + 4 <= bytes.len() {
            if &bytes[i..i + 4] == b"<!--" {
                // emit prior text with placeholder substitution
                if last_emit < i {
                    let segment = &input[last_emit..i];
                    out.push_str(&self.render_text_segment(
                        segment,
                        tpl_key,
                        default_vars,
                        default_vars_origin,
                        used_contents,
                        used_content_ptrs,
                    )?);
                }

                // find end of comment
                let mut end = i + 4;
                while end + 3 <= bytes.len() && &bytes[end..end + 3] != b"-->" {
                    end += 1;
                }
                if end + 3 > bytes.len() {
                    break;
                }
                let inner = &input[i + 4..end];

                if let Some(dir) = parse_directive(inner) {
                    match dir {
                        Directive::Snippet(sd) => {
                            out.push_str(
                                &self.render_snippet(
                                    &sd,
                                    tpl_key,
                                    default_vars,
                                    default_vars_origin,
                                    used_snippets,
                                    used_contents,
                                    used_content_ptrs,
                                    stack,
                                )?,
                            );
                        }
                    }
                } else {
                    // keep comment verbatim
                    out.push_str(&input[i..end + 3]);
                }

                i = end + 3;
                last_emit = i;
                continue;
            }
            i += 1;
        }

        // tail
        if last_emit < input.len() {
            let segment = &input[last_emit..];
            out.push_str(&self.render_text_segment(
                segment,
                tpl_key,
                default_vars,
                default_vars_origin,
                used_contents,
                used_content_ptrs,
            )?);
        }
        Ok(out)
    }

    /// Substitute placeholders in plain text segments.
    /// Namespaced `{{file/key}}` → pointer dep for `<file>.toml::path=<key>`
    /// Unqualified/dotted from template vars → pointer dep for `<tpl>.toml::path=<key>`
    fn render_text_segment(
        &mut self,
        segment: &str,
        _tpl_key: &str,
        default_vars: &HashMap<String, String>,
        default_vars_origin: Option<&str>,
        _used_contents: &mut Vec<String>,
        used_content_ptrs: &mut Vec<String>,
    ) -> Result<String> {
        let mut deps = PtrDeps { used_content_ptrs };
        let content_dir = &self.content_dir;

        let mut resolver = |raw_key: &str| -> Option<String> {
            if let Some((file, key)) = split_ns(raw_key) {
                let fpath = join_content_file(content_dir, file);
                let frel = path_no_ext(&fpath, content_dir).unwrap_or_else(|| file.to_string()) + ".toml";
                if let Ok(vars) = cache::load_vars(&mut self.caches, &frel, &fpath) {
                    // pointer dep for the namespaced key
                    deps.add(format!("{}::path={}", frel, key));
                    return vars.get(key).cloned();
                }
                // fall back to template vars (dotted or plain)
                if let Some(v) = default_vars.get(&format!("{}.{}", file, key)).cloned() {
                    if let Some(origin) = default_vars_origin {
                        deps.add(format!("{}::path={}", origin, format!("{}.{}", file, key)));
                    }
                    return Some(v);
                }
                if let Some(v) = default_vars.get(key).cloned() {
                    if let Some(origin) = default_vars_origin {
                        deps.add(format!("{}::path={}", origin, key));
                    }
                    return Some(v);
                }
                None
            } else {
                // unqualified: only from template vars here
                if let Some(v) = default_vars.get(raw_key).cloned() {
                    if let Some(origin) = default_vars_origin {
                        deps.add(format!("{}::path={}", origin, raw_key));
                    }
                    return Some(v);
                }
                None
            }
        };

        Ok(render_with_resolver(segment, &mut resolver))
    }

    /// Render a snippet (recursively resolves nested snippets).
    fn render_snippet(
        &mut self,
        sd: &SnippetDirective,
        _tpl_key: &str,
        tpl_vars: &HashMap<String, String>,
        tpl_vars_origin: Option<&str>,
        used_snippets: &mut Vec<String>,
        used_contents: &mut Vec<String>,
        used_content_ptrs: &mut Vec<String>,
        stack: &mut HashSet<String>,
    ) -> Result<String> {
        // cycle detection
        if !stack.insert(sd.name.clone()) {
            log::warn!("[cycle] snippet {} skipped (cycle)", sd.name);
            return Ok(format!("<!-- cycle: {} -->", sd.name));
        }

        // snippet file
        let snippet_file = join_snippet_file(&self.snippets_dir, &sd.name);
        let snippet_rel = match path_no_ext(&snippet_file, &self.snippets_dir) {
            Some(s) => s + ".htmls",
            None => format!("{}.htmls", sd.name),
        };
        if !used_snippets.contains(&snippet_rel) {
            used_snippets.push(snippet_rel.clone());
        }

        // load snippet HTML
        let snip = match cache::load_snippet(&mut self.caches, &snippet_rel, &snippet_file) {
            Ok(s) => s,
            Err(_) => {
                log::warn!("→ [snippet] not found: {}", snippet_file.display());
                stack.remove(&sd.name);
                return Ok(format!("<!-- @snippet not found: {} -->", sd.name));
            }
        };

        // Determine base vars for this snippet
        let mut base_vars: HashMap<String, String> = HashMap::new();
        let mut base_origin: Option<String> = None;

        if let Some(cp) = &sd.content {
            // load from content/<cp.file>.toml and optionally project cp.path
            let fpath = join_content_file(&self.content_dir, &cp.file);
            let frel =
                path_no_ext(&fpath, &self.content_dir).unwrap_or_else(|| cp.file.clone()) + ".toml";
            if fpath.exists() {
                let was_cached = self.caches.vars.contains_key(&frel);
                match cache::load_vars(&mut self.caches, &frel, &fpath) {
                    Ok(all) => {
                        if !was_cached { log::debug!("→ [content] {} ({} keys)", fpath.display(), all.len()); }
                        if let Some(p) = &cp.path {
                            // pointer dep ONLY
                            let key = format!("{}::path={}", frel, p);
                            if !used_content_ptrs.contains(&key) { used_content_ptrs.push(key); }
                            base_vars = project_prefix(&all, p);
                            base_origin = Some(frel.clone()); // for dotted fallbacks within this subtree
                        } else {
                            // empty path represents whole-doc pointer
                            let key = format!("{}::path=", frel);
                            if !used_content_ptrs.contains(&key) { used_content_ptrs.push(key); }
                            base_vars = all;
                            base_origin = Some(frel.clone());
                        }
                    }
                    Err(e) => {
                        if !was_cached { log::warn!("→ [content] parse error: {} - {}", fpath.display(), e); }
                    }
                }
            }
        } else {
            // snippet’s own content TOML (file-level dep is OK here)
            let snip_vars_path = join_content_file(&self.content_dir, &sd.name);
            let snip_vars_rel = match path_no_ext(&snip_vars_path, &self.content_dir) {
                Some(s) => s + ".toml",
                None => format!("{}.toml", sd.name),
            };
            if snip_vars_path.exists() {
                if !used_contents.contains(&snip_vars_rel) {
                    used_contents.push(snip_vars_rel.clone());
                }
                let was_cached = self.caches.vars.contains_key(&snip_vars_rel);
                match cache::load_vars(&mut self.caches, &snip_vars_rel, &snip_vars_path) {
                    Ok(m) => {
                        if !was_cached { log::debug!("→ [content] {} ({} keys)", snip_vars_path.display(), m.len()); }
                        base_vars = m;
                        base_origin = Some(snip_vars_rel.clone());
                    }
                    Err(e) => {
                        if !was_cached { log::warn!("→ [content] parse error: {} - {}", snip_vars_path.display(), e); }
                    }
                }
            }
        }

        // render by mode
        let rendered = match sd.mode {
            SnippetMode::Single => {
                // Process conditionals based on presence-only flags before any resolver work
                let conditionals_processed = self.process_conditionals(&snip.html, &sd.flags);

                // borrow references (no clones) and collect pointer deps via PtrDeps
                let content_dir_for_resolver = &self.content_dir;
                let base_vars_for_resolver = &base_vars;
                let base_origin_opt = base_origin.as_deref();
                let tpl_vars_for_resolver = tpl_vars;
                let tpl_origin_opt = tpl_vars_origin;

                let mut deps = PtrDeps { used_content_ptrs };
                let mut resolver = |raw_key: &str| -> Option<String> {
                    if let Some((file, key)) = split_ns(raw_key) {
                        let fpath = join_content_file(content_dir_for_resolver, file);
                        let frel = path_no_ext(&fpath, content_dir_for_resolver)
                            .unwrap_or_else(|| file.to_string())
                            + ".toml";
                        if let Ok(vars) = cache::load_vars(&mut self.caches, &frel, &fpath) {
                            if let Some(v) = vars.get(key).cloned() {
                                // record pointer dep on the external content file
                                deps.add(format!("{}::path={}", frel, key));
                                return Some(v);
                            }
                        }
                        // dotted in base_vars
                        if let Some(v) = base_vars_for_resolver
                            .get(&format!("{}.{}", file, key))
                            .cloned()
                        {
                            if let Some(origin) = base_origin_opt {
                                deps.add(format!("{}::path={}", origin, format!("{}.{}", file, key)));
                            }
                            return Some(v);
                        }
                        // dotted in tpl_vars
                        if let Some(v) = tpl_vars_for_resolver
                            .get(&format!("{}.{}", file, key))
                            .cloned()
                        {
                            if let Some(origin) = tpl_origin_opt {
                                deps.add(format!("{}::path={}", origin, format!("{}.{}", file, key)));
                            }
                            return Some(v);
                        }
                        // plain in base/tpl
                        if let Some(v) = base_vars_for_resolver.get(key).cloned() {
                            if let Some(origin) = base_origin_opt {
                                deps.add(format!("{}::path={}", origin, key));
                            }
                            return Some(v);
                        }
                        if let Some(v) = tpl_vars_for_resolver.get(key).cloned() {
                            if let Some(origin) = tpl_origin_opt {
                                deps.add(format!("{}::path={}", origin, key));
                            }
                            return Some(v);
                        }
                        None
                    } else {
                        if let Some(v) = base_vars_for_resolver.get(raw_key).cloned() {
                            if let Some(origin) = base_origin_opt {
                                deps.add(format!("{}::path={}", origin, raw_key));
                            }
                            return Some(v);
                        }
                        if let Some(v) = tpl_vars_for_resolver.get(raw_key).cloned() {
                            if let Some(origin) = tpl_origin_opt {
                                deps.add(format!("{}::path={}", origin, raw_key));
                            }
                            return Some(v);
                        }
                        None
                    }
                };

                let substituted = render_with_resolver(&conditionals_processed, &mut resolver);
                self.render_buffer(
                    &substituted,
                    "",
                    tpl_vars,
                    tpl_vars_origin,
                    used_snippets,
                    used_contents,
                    used_content_ptrs,
                    stack,
                )?
            }
            SnippetMode::List => {
                let list_items = if let Some(cp) = &sd.content {
                    let fpath = join_content_file(&self.content_dir, &cp.file);
                    let frel =
                        path_no_ext(&fpath, &self.content_dir).unwrap_or_else(|| cp.file.clone())
                            + ".toml";
                    if fpath.exists() {
                        let was_cached = {
                            let key = match cp.path.as_deref() {
                                Some(p) if !p.is_empty() => {
                                    format!("{}::path={}", fpath.to_string_lossy(), p)
                                }
                                _ => format!("{}::path=", fpath.to_string_lossy()),
                            };
                            self.caches.list_ptr.contains_key(&key)
                        };
                        let list = cache::load_list_from_pointer(
                            &mut self.caches,
                            &fpath,
                            cp.path.as_deref(),
                        )?;
                        // Record pointer: explicit path, or implicit `items` if present at top-level, else whole-doc
                        let key = if let Some(p) = &cp.path {
                            format!("{}::path={}", frel, p)
                        } else {
                            // Try to prefer `items` when defined at the root
                            let mut prefer_items = false;
                            if let Some(toml_val) = self.caches.peek_toml(&frel) {
                                prefer_items = toml_val.get("items").map(|v| v.is_array()).unwrap_or(false);
                            } else if cache::load_vars(&mut self.caches, &frel, &fpath).is_ok() {
                                if let Some(toml_val) = self.caches.peek_toml(&frel) {
                                    prefer_items = toml_val.get("items").map(|v| v.is_array()).unwrap_or(false);
                                }
                            }
                            if prefer_items {
                                format!("{}::path=items", frel)
                            } else {
                                format!("{}::path=", frel)
                            }
                        };
                        if !used_content_ptrs.contains(&key) { used_content_ptrs.push(key); }
                        if !was_cached { log::debug!("→ [content] {} ({} items)", fpath.display(), list.items.len()); }
                        list.items
                    } else {
                        Vec::new()
                    }
                } else {
                    // snippet’s own TOML top-level `items = [ ... ]` (file-level ok)
                    let snip_vars_path = join_content_file(&self.content_dir, &sd.name);
                    let snip_vars_rel = match path_no_ext(&snip_vars_path, &self.content_dir) {
                        Some(s) => s + ".toml",
                        None => format!("{}.toml", sd.name),
                    };
                    if snip_vars_path.exists() {
                        if !used_contents.contains(&snip_vars_rel) {
                            used_contents.push(snip_vars_rel.clone());
                        }
                        let was_cached = self.caches.lists.contains_key(&snip_vars_rel);
                        let list = cache::load_list(&mut self.caches, &snip_vars_rel, &snip_vars_path)?;
                        if !was_cached { log::debug!("→ [content] {} ({} items)", snip_vars_path.display(), list.items.len()); }
                        list.items
                    } else {
                        Vec::new()
                    }
                };

                // Per-item resolver (read-only w.r.t. deps; pointer deps for list source already recorded)
                let content_dir_shared = self.content_dir.clone();
                let tpl_vars_shared = tpl_vars;

                // Preprocess conditionals once per snippet, before per-item substitution
                let preprocessed_once = self.process_conditionals(&snip.html, &sd.flags);
                let mut combined = String::new();
                for item in list_items.into_iter() {
                    let item = item.clone();
                    let mut deps = PtrDeps { used_content_ptrs };
                    let mut resolver = |raw_key: &str| -> Option<String> {
                        if let Some((file, key)) = split_ns(raw_key) {
                            let fpath = join_content_file(&content_dir_shared, file);
                            let frel = path_no_ext(&fpath, &content_dir_shared)
                                .unwrap_or_else(|| file.to_string())
                                + ".toml";
                            if let Ok(vars) = cache::load_vars(&mut self.caches, &frel, &fpath) {
                                if let Some(v) = vars.get(key).cloned() {
                                    // record pointer dep on the external content file
                                    deps.add(format!("{}::path={}", frel, key));
                                    return Some(v);
                                }
                            }
                            item.get(&format!("{}.{}", file, key))
                                .cloned()
                                .or_else(|| item.get(key).cloned())
                                .or_else(|| tpl_vars_shared.get(&format!("{}.{}", file, key)).cloned())
                                .or_else(|| tpl_vars_shared.get(key).cloned())
                        } else {
                            item.get(raw_key)
                                .cloned()
                                .or_else(|| tpl_vars_shared.get(raw_key).cloned())
                        }
                    };

                    let substituted = render_with_resolver(&preprocessed_once, &mut resolver);

                    combined.push_str(
                        &self.render_buffer(
                            &substituted,
                            "",
                            tpl_vars,
                            tpl_vars_origin,
                            used_snippets,
                            used_contents,
                            used_content_ptrs,
                            stack,
                        )?,
                    );
                }
                combined
            }
        };

        stack.remove(&sd.name);
        Ok(rendered)
    }

    pub fn invalidate_snippet(&mut self, rel_key_htmls: &str) {
        log::debug!("[cache] invalidate snippet {}", rel_key_htmls);
        self.caches.invalidate_snippet(rel_key_htmls);
    }

    pub fn invalidate_content(&mut self, rel_key_toml: &str) {
        log::debug!("[cache] invalidate content {}", rel_key_toml);
        self.caches.invalidate_content(rel_key_toml);
    }

    /// Resolve the content TOML for a template key.
    fn resolve_content_file(&self, key: &str) -> (PathBuf, String) {
        use std::ffi::OsStr;

        let rel_from_abs = |abs: &PathBuf| {
            path_no_ext(abs, &self.content_dir)
                .unwrap_or_else(|| key.to_string())
                + ".toml"
        };

        // exact
        let exact = join_content_file(&self.content_dir, key);
        if exact.exists() {
            let rel = rel_from_abs(&exact);
            log::debug!("→ [content:resolve] exact match '{}' -> {}", key, exact.display());
            return (exact, rel);
        }

        // basename
        let base = Path::new(key)
            .file_stem()
            .and_then(OsStr::to_str)
            .unwrap_or(key);
        let base_path = join_content_file(&self.content_dir, base);
        if base_path.exists() {
            let rel = rel_from_abs(&base_path);
            log::debug!("→ [content:resolve] basename '{}' -> {}", key, base_path.display());
            return (base_path, rel);
        }

        // not found → return exact path; loaders will treat as empty vars
        let rel = rel_from_abs(&exact);
        log::debug!("→ [content:resolve] none for '{}' (will be empty)", key);
        (exact, rel)
    }
}

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

    fn unique_tmp_dir(prefix: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        let ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        p.push(format!("{}_{}", prefix, ts));
        p
    }

    #[test]
    fn snippet_conditionals_truthy_and_falsy() -> Result<()> {
        // layout
        let base = unique_tmp_dir("snip_cond");
        let src = base.join("src");
        let dist = base.join("dist");
        let snippets = base.join("snippets");
        let content = base.join("content");
        let static_dir = base.join("static");
        fs::create_dir_all(&src)?;
        fs::create_dir_all(&dist)?;
        fs::create_dir_all(&snippets.join("components"))?;
        fs::create_dir_all(&content)?;
        fs::create_dir_all(&static_dir)?;

        // snippet with conditional
        let snip_path = snippets.join("components").join("card.htmls");
        fs::write(
            &snip_path,
            "A <!-- if audioPlayer -->AP<!-- else -->NOAP<!-- endif --> Z",
        )?;

        // template with flag
        let tpl_with_flag = src.join("withflag.htmlt");
        fs::write(
            &tpl_with_flag,
            "Before <!-- @snippet name=\"components/card\" audioPlayer --> After",
        )?;

        // template without flag
        let tpl_no_flag = src.join("noflag.htmlt");
        fs::write(
            &tpl_no_flag,
            "Before <!-- @snippet name=\"components/card\" --> After",
        )?;

        let mut engine = Engine::new(src.clone(), dist.clone(), snippets.clone(), content, static_dir, false)?;

        // build with flag
        let tpl_with_flag_abs = std::fs::canonicalize(&tpl_with_flag)?;
        engine.build_template(&tpl_with_flag_abs.as_path())?;
        let out1 = fs::read_to_string(dist.join("withflag.html"))?;
        assert!(out1.contains("A AP Z"), "output was: {}", out1);
        assert!(!out1.contains("if audioPlayer"), "should strip conditional comments");

        // build without flag
        let tpl_no_flag_abs = std::fs::canonicalize(&tpl_no_flag)?;
        engine.build_template(&tpl_no_flag_abs.as_path())?;
        let out2 = fs::read_to_string(dist.join("noflag.html"))?;
        assert!(out2.contains("A NOAP Z"), "output was: {}", out2);
        assert!(!out2.contains("if audioPlayer"), "should strip conditional comments");

        // cleanup best-effort
        let _ = fs::remove_dir_all(&base);
        Ok(())
    }

    #[test]
    fn snippet_conditionals_nested() -> Result<()> {
        // layout
        let base = unique_tmp_dir("snip_nested");
        let src = base.join("src");
        let dist = base.join("dist");
        let snippets = base.join("snippets");
        let content = base.join("content");
        let static_dir = base.join("static");
        fs::create_dir_all(&src)?;
        fs::create_dir_all(&dist)?;
        fs::create_dir_all(&snippets)?;
        fs::create_dir_all(&content)?;
        fs::create_dir_all(&static_dir)?;

        // snippet with nested conditional
        fs::write(
            snippets.join("nested.htmls"),
            "BEFORE <!-- if outer -->X <!-- if inner -->IN<!-- endif --> Y<!-- else -->Z<!-- endif --> AFTER",
        )?;

        // template invoking with flags: outer=true, inner=false
        let tpl = src.join("t.htmlt");
        fs::write(
            &tpl,
            "<!-- @snippet name=\"nested\" outer -->",
        )?;

        let mut engine = Engine::new(src.clone(), dist.clone(), snippets.clone(), content, static_dir, false)?;
        let tpl_abs = std::fs::canonicalize(&tpl)?;
        engine.build_template(&tpl_abs)?;
        let out = fs::read_to_string(dist.join("t.html"))?;
        assert!(out.contains("BEFORE X  Y AFTER"));

        let _ = fs::remove_dir_all(&base);
        Ok(())
    }
}
