use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use anyhow::{anyhow, Context, Result};
use tokio::sync::RwLock;
use tracing::{debug, info};

use crate::db::LanceDb;
use crate::types::{Recipe, RecipeVariable};

// ---------------------------------------------------------------------------
// Bundled starter recipe templates
// ---------------------------------------------------------------------------

const BUNDLED_RECIPE_REFACTOR: &str = r#"---
description: "Refactor code to improve readability, performance, or structure"
variables:
  - name: code
    description: "The code to refactor"
    required: true
  - name: goal
    description: "What the refactoring should achieve"
    required: true
  - name: language
    description: "Programming language"
    required: false
    default: "auto-detect"
---
Refactor the following {{language}} code to {{goal}}.

## Code
```
{{code}}
```

## Requirements
- Preserve the existing public API and behavior
- Improve readability and maintainability
- Add comments only where the logic is non-obvious
- Return the refactored code with a brief summary of changes
"#;

const BUNDLED_RECIPE_TEST_GEN: &str = r#"---
description: "Generate tests for existing code"
variables:
  - name: code
    description: "The code to generate tests for"
    required: true
  - name: framework
    description: "Testing framework to use"
    required: false
    default: "auto-detect"
  - name: style
    description: "Test style preference"
    required: false
    default: "unit"
---
Generate {{style}} tests for the following code using {{framework}}.

## Code Under Test
```
{{code}}
```

## Requirements
- Cover happy path, edge cases, and error conditions
- Use descriptive test names that explain the scenario
- Keep tests focused — one assertion per concept
- Include setup/teardown if needed
"#;

const BUNDLED_RECIPE_DOC_GEN: &str = r#"---
description: "Generate documentation for code"
variables:
  - name: code
    description: "The code to document"
    required: true
  - name: format
    description: "Documentation format"
    required: false
    default: "markdown"
  - name: audience
    description: "Target audience"
    required: false
    default: "developers"
---
Generate {{format}} documentation for the following code, targeted at {{audience}}.

## Code
```
{{code}}
```

## Requirements
- Explain what the code does at a high level
- Document public APIs with parameters and return values
- Include usage examples where helpful
- Note any important caveats or limitations
"#;

/// Mapping of bundled recipe (file-stem, content) pairs.
const BUNDLED_RECIPES: &[(&str, &str)] = &[
    ("refactor", BUNDLED_RECIPE_REFACTOR),
    ("test_gen", BUNDLED_RECIPE_TEST_GEN),
    ("doc_gen", BUNDLED_RECIPE_DOC_GEN),
];

// ---------------------------------------------------------------------------
// Recipes Manager
// ---------------------------------------------------------------------------

/// Manages portable prompt templates (recipes) stored as Markdown files on
/// disk.  Recipes are resolved from two directories with project-level
/// taking priority over user-level:
///
/// ```text
/// <workspace>/.furnace/recipes/   <-- project-level (checked first)
///   custom_recipe.md
///
/// ~/.furnace/recipes/             <-- user-level (fallback)
///   refactor.md
///   refactor.claude.md           <-- LLM-specific variant
///   test_gen.md
///   doc_gen.md
/// ```
pub struct RecipeManager {
    /// Project-level recipes directory (checked first, saves go here).
    recipes_dir: PathBuf,
    /// User-level recipes directory (fallback, bundled recipes installed here).
    user_recipes_dir: PathBuf,
    cache: Arc<RwLock<HashMap<String, Recipe>>>,
    /// Optional LanceDb for semantic recipe search.
    lancedb: Option<Arc<LanceDb>>,
}

impl RecipeManager {
    // -- construction -------------------------------------------------------

    /// Create a new `RecipeManager` with two-tier directory resolution.
    ///
    /// - `project_recipes_dir`: project-level (checked first, writes go here)
    /// - `user_recipes_dir`: user-level (fallback, bundled recipes installed here)
    ///
    /// Both directories are created if they do not already exist.
    pub async fn new(project_recipes_dir: PathBuf, user_recipes_dir: PathBuf) -> Result<Self> {
        tokio::fs::create_dir_all(&project_recipes_dir)
            .await
            .with_context(|| format!("Failed to create project recipes directory: {}", project_recipes_dir.display()))?;
        tokio::fs::create_dir_all(&user_recipes_dir)
            .await
            .with_context(|| format!("Failed to create user recipes directory: {}", user_recipes_dir.display()))?;

        info!(
            project = %project_recipes_dir.display(),
            user = %user_recipes_dir.display(),
            "RecipeManager initialised"
        );

        let mgr = Self {
            recipes_dir: project_recipes_dir,
            user_recipes_dir,
            cache: Arc::new(RwLock::new(HashMap::new())),
            lancedb: None,
        };

        mgr.install_bundled_recipes_if_empty().await?;

        Ok(mgr)
    }

    /// Attach a LanceDb instance for semantic recipe embeddings.
    pub fn with_lancedb(mut self, lancedb: Arc<LanceDb>) -> Self {
        self.lancedb = Some(lancedb);
        self
    }

    /// Ensure all on-disk recipes are indexed in LanceDb.
    ///
    /// Called after construction when LanceDb is available. Loads each
    /// recipe and upserts its embedding if not already present.
    pub async fn ensure_recipes_indexed(&self) {
        let lancedb = match self.lancedb {
            Some(ref db) => db,
            None => return,
        };

        let names = match self.list_recipes().await {
            Ok(n) => n,
            Err(_) => return,
        };

        for name in &names {
            if let Ok(recipe) = self.load_recipe(name).await {
                let embed_text = format!("{}: {}\n\n{}", recipe.name, recipe.description, recipe.template);
                let embedding = crate::search::embedder::embed_text(&embed_text);
                if let Err(e) = lancedb.upsert_recipe(&recipe.name, &embed_text, embedding).await {
                    tracing::warn!(recipe = %recipe.name, error = %e, "Failed to index recipe in LanceDb");
                }
            }
        }

        tracing::info!(count = names.len(), "Recipes indexed in LanceDb");
    }

    // -- loading / caching --------------------------------------------------

    /// Load a recipe by name.
    ///
    /// Checks the in-memory cache first; on a miss the file is looked up
    /// in the project-level directory first, then the user-level directory.
    pub async fn load_recipe(&self, name: &str) -> Result<Recipe> {
        // Fast-path: check cache.
        {
            let cache = self.cache.read().await;
            if let Some(recipe) = cache.get(name) {
                debug!("Recipe '{}' loaded from cache", name);
                return Ok(recipe.clone());
            }
        }

        // Cache miss -- try project dir first, then user dir.
        let project_path = self.recipe_path(name);
        let user_path = self.user_recipe_path(name);

        let (content, source) = if project_path.exists() {
            let c = tokio::fs::read_to_string(&project_path)
                .await
                .with_context(|| format!("Failed to read recipe file: {}", project_path.display()))?;
            (c, "project")
        } else {
            let c = tokio::fs::read_to_string(&user_path)
                .await
                .with_context(|| format!("Failed to read recipe file: {}", user_path.display()))?;
            (c, "user")
        };

        let recipe = self.parse_recipe_file(&content, name)?;

        // Insert into cache.
        {
            let mut cache = self.cache.write().await;
            cache.insert(name.to_string(), recipe.clone());
        }

        debug!("Recipe '{}' loaded from {} dir and cached", name, source);

        // Fire-and-forget: embed into LanceDb for semantic search.
        if let Some(ref lancedb) = self.lancedb {
            let embed_text = format!("{}: {}\n\n{}", recipe.name, recipe.description, recipe.template);
            let embedding = crate::search::embedder::embed_text(&embed_text);
            if let Err(e) = lancedb.upsert_recipe(&recipe.name, &embed_text, embedding).await {
                tracing::warn!(recipe = %recipe.name, error = %e, "Failed to embed recipe in LanceDb");
            }
        }

        Ok(recipe)
    }

    /// Clear the cached entry for `name` so that the next `load_recipe` call
    /// re-reads from disk.
    pub async fn reload_recipe(&self, name: &str) -> Result<()> {
        let mut cache = self.cache.write().await;
        cache.remove(name);
        debug!("Cache entry for recipe '{}' cleared", name);
        Ok(())
    }

    // -- listing ------------------------------------------------------------

    /// List the names of all `.md` recipe files across both directories.
    ///
    /// Returns a deduplicated, sorted list. Project-level recipes shadow
    /// user-level recipes of the same name (the dedup is by name; both are
    /// present but the sorted vec only contains unique names).
    pub async fn list_recipes(&self) -> Result<Vec<String>> {
        let mut names = std::collections::BTreeSet::new();

        // Scan project-level directory.
        Self::collect_recipe_names(&self.recipes_dir, &mut names).await?;

        // Scan user-level directory.
        Self::collect_recipe_names(&self.user_recipes_dir, &mut names).await?;

        Ok(names.into_iter().collect())
    }

    /// Helper: read `.md` filenames from a directory into a set.
    async fn collect_recipe_names(
        dir: &PathBuf,
        names: &mut std::collections::BTreeSet<String>,
    ) -> Result<()> {
        let mut entries = match tokio::fs::read_dir(dir).await {
            Ok(e) => e,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(e) => {
                return Err(anyhow!(
                    "Failed to read recipes directory {}: {}",
                    dir.display(),
                    e
                ))
            }
        };

        while let Some(entry) = entries.next_entry().await? {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) == Some("md") {
                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
                    names.insert(stem.to_string());
                }
            }
        }
        Ok(())
    }

    /// List all recipes with their descriptions.
    ///
    /// Loads each recipe from disk (or cache) to extract the description
    /// from the frontmatter.
    pub async fn list_recipes_with_descriptions(&self) -> Result<Vec<(String, String)>> {
        let names = self.list_recipes().await?;
        let mut result = Vec::with_capacity(names.len());
        for name in &names {
            let desc = match self.load_recipe(name).await {
                Ok(recipe) => recipe.description,
                Err(_) => String::new(),
            };
            result.push((name.clone(), desc));
        }
        Ok(result)
    }

    /// Install bundled starter recipes into the user-level directory if no
    /// recipes exist in either directory. Called automatically from `new()`.
    async fn install_bundled_recipes_if_empty(&self) -> Result<()> {
        let existing = self.list_recipes().await?;
        if !existing.is_empty() {
            return Ok(());
        }

        info!("No recipes found — installing bundled starter recipes to user directory");
        for &(name, content) in BUNDLED_RECIPES {
            let path = self.user_recipe_path(name);
            tokio::fs::write(&path, content.as_bytes())
                .await
                .with_context(|| format!("Failed to write bundled recipe: {}", path.display()))?;
            info!("Installed bundled recipe: {}", name);
        }
        Ok(())
    }

    // -- persistence --------------------------------------------------------

    /// Write a recipe to disk as a Markdown file with YAML-like frontmatter.
    pub async fn save_recipe(&self, recipe: &Recipe) -> Result<()> {
        let content = self.serialize_recipe(recipe);
        let path = self.recipe_path(&recipe.name);

        tokio::fs::write(&path, content.as_bytes())
            .await
            .with_context(|| format!("Failed to write recipe file: {}", path.display()))?;

        // Update cache.
        {
            let mut cache = self.cache.write().await;
            cache.insert(recipe.name.clone(), recipe.clone());
        }

        // Fire-and-forget: embed into LanceDb for semantic search.
        if let Some(ref lancedb) = self.lancedb {
            let embed_text = format!("{}: {}\n\n{}", recipe.name, recipe.description, recipe.template);
            let embedding = crate::search::embedder::embed_text(&embed_text);
            if let Err(e) = lancedb.upsert_recipe(&recipe.name, &embed_text, embedding).await {
                tracing::warn!(recipe = %recipe.name, error = %e, "Failed to embed recipe in LanceDb");
            }
        }

        info!("Recipe '{}' saved to {}", recipe.name, path.display());
        Ok(())
    }

    /// Delete a recipe file from disk and evict it from the cache.
    pub async fn delete_recipe(&self, name: &str) -> Result<()> {
        let path = self.recipe_path(name);

        if !path.exists() {
            return Err(anyhow!("Recipe file does not exist: {}", path.display()));
        }

        tokio::fs::remove_file(&path)
            .await
            .with_context(|| format!("Failed to delete recipe file: {}", path.display()))?;

        {
            let mut cache = self.cache.write().await;
            cache.remove(name);
        }

        // Remove from LanceDb.
        if let Some(ref lancedb) = self.lancedb {
            if let Err(e) = lancedb.remove_recipe(name).await {
                tracing::warn!(recipe = %name, error = %e, "Failed to remove recipe from LanceDb");
            }
        }

        info!("Recipe '{}' deleted", name);
        Ok(())
    }

    // -- rendering ----------------------------------------------------------

    /// Load a recipe and render its template by replacing `{{variable}}`
    /// placeholders with the provided values.
    ///
    /// Missing *required* variables that have no default will cause an error.
    pub async fn render_recipe(
        &self,
        name: &str,
        vars: &HashMap<String, String>,
    ) -> Result<String> {
        let recipe = self.load_recipe(name).await?;

        // Build the effective variable map: caller values override defaults.
        let mut effective: HashMap<String, String> = HashMap::new();
        for var in &recipe.variables {
            if let Some(val) = vars.get(&var.name) {
                if var.required && val.trim().is_empty() {
                    return Err(anyhow!(
                        "Required variable '{}' must not be empty for recipe '{}'",
                        var.name,
                        name
                    ));
                }
                effective.insert(var.name.clone(), val.clone());
            } else if let Some(ref default) = var.default {
                effective.insert(var.name.clone(), default.clone());
            } else if var.required {
                return Err(anyhow!(
                    "Required variable '{}' not provided for recipe '{}'",
                    var.name,
                    name
                ));
            }
        }

        let mut rendered = recipe.template.clone();
        for (key, value) in &effective {
            let placeholder = format!("{{{{{}}}}}", key); // produces {{key}}
            rendered = rendered.replace(&placeholder, value);
        }

        Ok(rendered)
    }

    // -- LLM-specific variant lookup ----------------------------------------

    /// Render a recipe with automatic LLM variant selection.
    ///
    /// Uses `llm_provider.name()` to auto-detect the LLM and loads the
    /// appropriate variant (e.g. `recipe_creation.claude.md`). Falls back to
    /// the base recipe if no variant exists.
    pub async fn render_recipe_auto(
        &self,
        name: &str,
        vars: &HashMap<String, String>,
        llm: &dyn crate::types::LlmProvider,
    ) -> Result<String> {
        let recipe = self.get_recipe_for_llm(name, llm.name()).await?;

        let mut effective: HashMap<String, String> = HashMap::new();
        for var in &recipe.variables {
            if let Some(val) = vars.get(&var.name) {
                if var.required && val.trim().is_empty() {
                    return Err(anyhow!(
                        "Required variable '{}' must not be empty for recipe '{}'",
                        var.name,
                        name
                    ));
                }
                effective.insert(var.name.clone(), val.clone());
            } else if let Some(ref default) = var.default {
                effective.insert(var.name.clone(), default.clone());
            } else if var.required {
                return Err(anyhow!(
                    "Required variable '{}' not provided for recipe '{}'",
                    var.name,
                    name
                ));
            }
        }

        let mut rendered = recipe.template.clone();
        for (key, val) in &effective {
            rendered = rendered.replace(&format!("{{{{{}}}}}", key), val);
        }

        Ok(rendered)
    }

    /// Try to load an LLM-specific variant of a recipe.
    ///
    /// Looks for `<name>.<llm>.md` first; falls back to `<name>.md` if the
    /// variant file does not exist.
    pub async fn get_recipe_for_llm(&self, name: &str, llm: &str) -> Result<Recipe> {
        let variant_name = format!("{}.{}", name, llm);
        let project_variant = self.recipe_path(&variant_name);
        let user_variant = self.user_recipe_path(&variant_name);

        if project_variant.exists() || user_variant.exists() {
            debug!(
                "Found LLM-specific variant: {}.md",
                variant_name
            );
            self.load_recipe(&variant_name).await
        } else {
            debug!(
                "No variant for LLM '{}', falling back to {}.md",
                llm, name
            );
            self.load_recipe(name).await
        }
    }

    // -- parsing / serialisation (public for testing) -----------------------

    /// Parse a recipe Markdown file into a `Recipe` struct.
    ///
    /// Expected format:
    /// ```text
    /// ---
    /// description: Short description
    /// variables:
    ///   - name: intent
    ///     description: User's intent
    ///     required: true
    ///   - name: context
    ///     description: Additional context
    ///     required: false
    ///     default: "none"
    /// llm_variant: claude
    /// ---
    /// Template body with {{intent}} and {{context}}.
    /// ```
    ///
    /// Frontmatter parsing is intentionally hand-rolled (line-by-line) so
    /// that we avoid pulling in a YAML crate.
    pub fn parse_recipe_file(&self, content: &str, name: &str) -> Result<Recipe> {
        let trimmed = content.trim();
        if !trimmed.starts_with("---") {
            return Err(anyhow!(
                "Recipe file '{}' does not start with frontmatter delimiter '---'",
                name
            ));
        }

        // Find the closing "---".
        let after_first = &trimmed[3..];
        let end_idx = after_first
            .find("\n---")
            .ok_or_else(|| anyhow!("Recipe file '{}' is missing closing frontmatter '---'", name))?;

        let frontmatter = &after_first[..end_idx];
        // +1 to skip the newline, +3 for the "---"
        let body_start = 3 + end_idx + 1 + 3;
        let template = if body_start < trimmed.len() {
            trimmed[body_start..].trim().to_string()
        } else {
            String::new()
        };

        // Parse frontmatter line by line.
        let mut description = String::new();
        let mut llm_variant: Option<String> = None;
        let mut variables: Vec<RecipeVariable> = Vec::new();

        let mut in_variables_block = false;
        let mut current_var: Option<PartialVariable> = None;

        for raw_line in frontmatter.lines() {
            let line = raw_line.trim();
            if line.is_empty() {
                continue;
            }

            // Top-level keys (not indented in the variables list).
            if !in_variables_block || (!line.starts_with('-') && !line.starts_with("name:")
                && !line.starts_with("description:") && !line.starts_with("required:")
                && !line.starts_with("default:"))
            {
                if let Some(val) = strip_key(line, "description:") {
                    if !in_variables_block {
                        description = unquote(val);
                        continue;
                    }
                }
                if let Some(val) = strip_key(line, "llm_variant:") {
                    in_variables_block = false;
                    flush_var(&mut current_var, &mut variables);
                    llm_variant = Some(unquote(val));
                    continue;
                }
                if line == "variables:" {
                    in_variables_block = true;
                    continue;
                }
            }

            if in_variables_block {
                // New variable entry starts with "- name: ..."
                if line.starts_with("- name:") || line.starts_with("-name:") {
                    flush_var(&mut current_var, &mut variables);
                    let val = strip_key(line.trim_start_matches('-').trim(), "name:")
                        .unwrap_or("");
                    current_var = Some(PartialVariable {
                        name: unquote(val),
                        description: String::new(),
                        required: false,
                        default: None,
                    });
                    continue;
                }

                if let Some(ref mut var) = current_var {
                    if let Some(val) = strip_key(line, "description:") {
                        var.description = unquote(val);
                    } else if let Some(val) = strip_key(line, "required:") {
                        var.required = val.trim() == "true";
                    } else if let Some(val) = strip_key(line, "default:") {
                        var.default = Some(unquote(val));
                    }
                }
            }
        }

        // Flush any trailing variable.
        flush_var(&mut current_var, &mut variables);

        Ok(Recipe {
            name: name.to_string(),
            description,
            template,
            variables,
            llm_variant,
        })
    }

    /// Convert a `Recipe` back into Markdown with YAML-like frontmatter.
    pub fn serialize_recipe(&self, recipe: &Recipe) -> String {
        let mut out = String::new();
        out.push_str("---\n");

        out.push_str(&format!("description: \"{}\"\n", escape_yaml_string(&recipe.description)));

        if !recipe.variables.is_empty() {
            out.push_str("variables:\n");
            for var in &recipe.variables {
                out.push_str(&format!("  - name: {}\n", var.name));
                out.push_str(&format!(
                    "    description: \"{}\"\n",
                    escape_yaml_string(&var.description)
                ));
                out.push_str(&format!("    required: {}\n", var.required));
                if let Some(ref default) = var.default {
                    out.push_str(&format!("    default: \"{}\"\n", escape_yaml_string(default)));
                }
            }
        }

        if let Some(ref variant) = recipe.llm_variant {
            out.push_str(&format!("llm_variant: {}\n", variant));
        }

        out.push_str("---\n");
        out.push_str(&recipe.template);
        if !recipe.template.ends_with('\n') {
            out.push('\n');
        }
        out
    }

    // -- helpers (private) --------------------------------------------------

    /// Path in the project-level recipes directory.
    fn recipe_path(&self, name: &str) -> PathBuf {
        self.recipes_dir.join(format!("{}.md", name))
    }

    /// Path in the user-level recipes directory.
    fn user_recipe_path(&self, name: &str) -> PathBuf {
        self.user_recipes_dir.join(format!("{}.md", name))
    }
}

// ---------------------------------------------------------------------------
// Frontmatter parsing helpers
// ---------------------------------------------------------------------------

/// Intermediate accumulator used while parsing variable blocks.
struct PartialVariable {
    name: String,
    description: String,
    required: bool,
    default: Option<String>,
}

/// Flush the current partial variable into the output list.
fn flush_var(current: &mut Option<PartialVariable>, out: &mut Vec<RecipeVariable>) {
    if let Some(pv) = current.take() {
        out.push(RecipeVariable {
            name: pv.name,
            description: pv.description,
            required: pv.required,
            default: pv.default,
        });
    }
}

/// If `line` starts with `key` (case-sensitive), return the trimmed remainder.
fn strip_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
    if let Some(rest) = line.strip_prefix(key) {
        Some(rest.trim())
    } else {
        None
    }
}

/// Remove surrounding double quotes from a string value, if present.
fn unquote(s: &str) -> String {
    let t = s.trim();
    if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
        t[1..t.len() - 1].to_string()
    } else {
        t.to_string()
    }
}

/// Escape characters that are meaningful inside a YAML double-quoted string.
fn escape_yaml_string(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

// ===========================================================================
// Tests
// ===========================================================================

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

    // -- parsing / serialisation tests --------------------------------------

    fn manager_for_test(dir: &std::path::Path) -> RecipeManager {
        RecipeManager {
            recipes_dir: dir.to_path_buf(),
            user_recipes_dir: dir.to_path_buf(),
            cache: Arc::new(RwLock::new(HashMap::new())),
            lancedb: None,
        }
    }

    /// Create a RecipeManager with separate project and user dirs for testing.
    async fn make_two_dir_manager() -> (RecipeManager, tempfile::TempDir, tempfile::TempDir) {
        let project_dir = tempfile::tempdir().unwrap();
        let user_dir = tempfile::tempdir().unwrap();
        let mgr = RecipeManager::new(
            project_dir.path().to_path_buf(),
            user_dir.path().to_path_buf(),
        )
        .await
        .unwrap();
        (mgr, project_dir, user_dir)
    }

    #[test]
    fn test_parse_recipe_file_basic() {
        let content = r#"---
description: A test recipe
variables:
  - name: intent
    description: User's intent
    required: true
  - name: context
    description: Additional context
    required: false
    default: "none"
llm_variant: claude
---
Please do {{intent}} with {{context}}."#;

        let mgr = manager_for_test(std::path::Path::new("/tmp"));
        let recipe = mgr.parse_recipe_file(content, "test_recipe").unwrap();

        assert_eq!(recipe.name, "test_recipe");
        assert_eq!(recipe.description, "A test recipe");
        assert_eq!(recipe.variables.len(), 2);
        assert_eq!(recipe.variables[0].name, "intent");
        assert!(recipe.variables[0].required);
        assert_eq!(recipe.variables[1].name, "context");
        assert!(!recipe.variables[1].required);
        assert_eq!(recipe.variables[1].default, Some("none".into()));
        assert_eq!(recipe.llm_variant, Some("claude".into()));
        assert_eq!(recipe.template, "Please do {{intent}} with {{context}}.");
    }

    #[test]
    fn test_parse_recipe_file_no_variables() {
        let content = r#"---
description: Simple recipe
---
Just a plain template."#;

        let mgr = manager_for_test(std::path::Path::new("/tmp"));
        let recipe = mgr.parse_recipe_file(content, "simple").unwrap();

        assert_eq!(recipe.name, "simple");
        assert_eq!(recipe.description, "Simple recipe");
        assert!(recipe.variables.is_empty());
        assert!(recipe.llm_variant.is_none());
        assert_eq!(recipe.template, "Just a plain template.");
    }

    #[test]
    fn test_parse_recipe_file_missing_frontmatter() {
        let content = "No frontmatter here.";
        let mgr = manager_for_test(std::path::Path::new("/tmp"));
        let result = mgr.parse_recipe_file(content, "bad");
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("does not start with frontmatter"));
    }

    #[test]
    fn test_parse_recipe_file_unclosed_frontmatter() {
        let content = "---\ndescription: Oops\nNo closing marker.";
        let mgr = manager_for_test(std::path::Path::new("/tmp"));
        let result = mgr.parse_recipe_file(content, "unclosed");
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("missing closing frontmatter"));
    }

    #[test]
    fn test_serialize_recipe_roundtrip() {
        let recipe = Recipe {
            name: "roundtrip".into(),
            description: "A roundtrip test".into(),
            template: "Hello {{name}}, welcome to {{place}}.".into(),
            variables: vec![
                RecipeVariable {
                    name: "name".into(),
                    description: "User name".into(),
                    required: true,
                    default: None,
                },
                RecipeVariable {
                    name: "place".into(),
                    description: "Destination".into(),
                    required: false,
                    default: Some("FURNACE".into()),
                },
            ],
            llm_variant: Some("gpt4".into()),
        };

        let mgr = manager_for_test(std::path::Path::new("/tmp"));
        let serialized = mgr.serialize_recipe(&recipe);
        let parsed = mgr
            .parse_recipe_file(&serialized, "roundtrip")
            .expect("roundtrip should parse");

        assert_eq!(parsed.name, recipe.name);
        assert_eq!(parsed.description, recipe.description);
        assert_eq!(parsed.template, recipe.template);
        assert_eq!(parsed.variables.len(), recipe.variables.len());
        assert_eq!(parsed.variables[0].name, "name");
        assert!(parsed.variables[0].required);
        assert_eq!(parsed.variables[1].default, Some("FURNACE".into()));
        assert_eq!(parsed.llm_variant, Some("gpt4".into()));
    }

    // -- async tests --------------------------------------------------------

    #[tokio::test]
    async fn test_new_creates_directory() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("nested").join("recipes");
        assert!(!sub.exists());

        let user_dir = dir.path().join("user_recipes");
        let _mgr = RecipeManager::new(sub.clone(), user_dir).await.unwrap();
        assert!(sub.exists());
    }

    #[tokio::test]
    async fn test_save_and_load_recipe() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        let recipe = Recipe {
            name: "greet".into(),
            description: "Greeting recipe".into(),
            template: "Hello, {{user}}!".into(),
            variables: vec![RecipeVariable {
                name: "user".into(),
                description: "Name of the user".into(),
                required: true,
                default: None,
            }],
            llm_variant: None,
        };

        mgr.save_recipe(&recipe).await.unwrap();

        // Clear cache to force disk read.
        mgr.reload_recipe("greet").await.unwrap();

        let loaded = mgr.load_recipe("greet").await.unwrap();
        assert_eq!(loaded.name, "greet");
        assert_eq!(loaded.description, "Greeting recipe");
        assert_eq!(loaded.template, "Hello, {{user}}!");
        assert_eq!(loaded.variables.len(), 1);
    }

    #[tokio::test]
    async fn test_list_recipes() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        // Save two additional recipes on top of bundled ones.
        for name in &["beta_recipe", "alpha_recipe"] {
            let recipe = Recipe {
                name: name.to_string(),
                description: format!("{} desc", name),
                template: "tmpl".into(),
                variables: vec![],
                llm_variant: None,
            };
            mgr.save_recipe(&recipe).await.unwrap();
        }

        let names = mgr.list_recipes().await.unwrap();
        // 3 bundled + 2 custom = 5, sorted alphabetically
        assert_eq!(names.len(), 5);
        assert!(names.contains(&"alpha_recipe".to_string()));
        assert!(names.contains(&"beta_recipe".to_string()));
        // Verify sorted order
        let mut sorted = names.clone();
        sorted.sort();
        assert_eq!(names, sorted);
    }

    #[tokio::test]
    async fn test_delete_recipe() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        let recipe = Recipe {
            name: "ephemeral".into(),
            description: "Will be deleted".into(),
            template: "bye".into(),
            variables: vec![],
            llm_variant: None,
        };
        mgr.save_recipe(&recipe).await.unwrap();
        assert!(mgr.load_recipe("ephemeral").await.is_ok());

        mgr.delete_recipe("ephemeral").await.unwrap();
        assert!(mgr.load_recipe("ephemeral").await.is_err());
    }

    #[tokio::test]
    async fn test_render_recipe_substitution() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        let recipe = Recipe {
            name: "letter".into(),
            description: "A letter template".into(),
            template: "Dear {{recipient}},\n\n{{body}}\n\nFrom, {{sender}}".into(),
            variables: vec![
                RecipeVariable {
                    name: "recipient".into(),
                    description: "To whom".into(),
                    required: true,
                    default: None,
                },
                RecipeVariable {
                    name: "body".into(),
                    description: "Letter body".into(),
                    required: true,
                    default: None,
                },
                RecipeVariable {
                    name: "sender".into(),
                    description: "From whom".into(),
                    required: false,
                    default: Some("FURNACE".into()),
                },
            ],
            llm_variant: None,
        };
        mgr.save_recipe(&recipe).await.unwrap();

        let mut vars = HashMap::new();
        vars.insert("recipient".into(), "World".into());
        vars.insert("body".into(), "Greetings!".into());

        let rendered = mgr.render_recipe("letter", &vars).await.unwrap();
        assert_eq!(
            rendered,
            "Dear World,\n\nGreetings!\n\nFrom, FURNACE"
        );
    }

    #[tokio::test]
    async fn test_render_recipe_missing_required_var() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        let recipe = Recipe {
            name: "strict".into(),
            description: "Requires vars".into(),
            template: "{{must_have}}".into(),
            variables: vec![RecipeVariable {
                name: "must_have".into(),
                description: "Mandatory".into(),
                required: true,
                default: None,
            }],
            llm_variant: None,
        };
        mgr.save_recipe(&recipe).await.unwrap();

        let result = mgr.render_recipe("strict", &HashMap::new()).await;
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("Required variable"));
    }

    #[tokio::test]
    async fn test_get_recipe_for_llm_variant() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        // Base recipe.
        let base = Recipe {
            name: "codegen".into(),
            description: "Generic codegen".into(),
            template: "Generate code: {{intent}}".into(),
            variables: vec![RecipeVariable {
                name: "intent".into(),
                description: "What to generate".into(),
                required: true,
                default: None,
            }],
            llm_variant: None,
        };
        mgr.save_recipe(&base).await.unwrap();

        // Claude-specific variant.
        let variant = Recipe {
            name: "codegen.claude".into(),
            description: "Claude-specific codegen".into(),
            template: "As Claude, generate: {{intent}}".into(),
            variables: vec![RecipeVariable {
                name: "intent".into(),
                description: "What to generate".into(),
                required: true,
                default: None,
            }],
            llm_variant: Some("claude".into()),
        };
        mgr.save_recipe(&variant).await.unwrap();

        // Request Claude variant -- should find the specific file.
        let recipe = mgr.get_recipe_for_llm("codegen", "claude").await.unwrap();
        assert_eq!(recipe.name, "codegen.claude");
        assert!(recipe.template.contains("As Claude"));

        // Request GPT variant -- no specific file, should fall back.
        let recipe = mgr.get_recipe_for_llm("codegen", "gpt4").await.unwrap();
        assert_eq!(recipe.name, "codegen");
        assert!(recipe.template.contains("Generate code"));
    }

    #[tokio::test]
    async fn test_cache_is_populated_and_clearable() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        let recipe = Recipe {
            name: "cached".into(),
            description: "Will be cached".into(),
            template: "tmpl".into(),
            variables: vec![],
            llm_variant: None,
        };
        mgr.save_recipe(&recipe).await.unwrap();

        // Loading should populate cache.
        mgr.reload_recipe("cached").await.unwrap();
        let _ = mgr.load_recipe("cached").await.unwrap();
        {
            let cache = mgr.cache.read().await;
            assert!(cache.contains_key("cached"));
        }

        // Reload should clear it.
        mgr.reload_recipe("cached").await.unwrap();
        {
            let cache = mgr.cache.read().await;
            assert!(!cache.contains_key("cached"));
        }
    }

    // -- bundled recipes tests -----------------------------------------------

    #[tokio::test]
    async fn test_bundled_recipes_installed_in_empty_dir() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        let recipes = mgr.list_recipes().await.unwrap();
        assert_eq!(recipes.len(), 3, "Expected 3 bundled recipes, got: {:?}", recipes);
        assert!(recipes.contains(&"refactor".to_string()));
        assert!(recipes.contains(&"test_gen".to_string()));
        assert!(recipes.contains(&"doc_gen".to_string()));

        // Bundled recipes should be in user dir, not project dir
        assert!(
            _user_dir.path().join("refactor.md").exists(),
            "Bundled recipes should be in user dir"
        );
        assert!(
            !_project_dir.path().join("refactor.md").exists(),
            "Bundled recipes should NOT be in project dir"
        );
    }

    #[tokio::test]
    async fn test_bundled_recipes_not_installed_if_dir_has_recipes() {
        let project_dir = tempfile::tempdir().unwrap();
        let user_dir = tempfile::tempdir().unwrap();

        // Pre-populate the user directory with a custom recipe.
        let custom_path = user_dir.path().join("my_custom.md");
        tokio::fs::write(
            &custom_path,
            "---\ndescription: Custom\n---\nMy template.\n",
        )
        .await
        .unwrap();

        let mgr = RecipeManager::new(
            project_dir.path().to_path_buf(),
            user_dir.path().to_path_buf(),
        )
        .await
        .unwrap();

        let recipes = mgr.list_recipes().await.unwrap();
        // Only the pre-existing custom recipe should be present.
        assert_eq!(recipes.len(), 1);
        assert_eq!(recipes[0], "my_custom");
    }

    #[tokio::test]
    async fn test_bundled_recipe_content_parses_correctly() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        for &(name, _) in BUNDLED_RECIPES {
            let recipe = mgr.load_recipe(name).await
                .unwrap_or_else(|e| panic!("Failed to load bundled recipe '{}': {}", name, e));
            assert!(!recipe.description.is_empty(), "Recipe '{}' has empty description", name);
            assert!(!recipe.template.is_empty(), "Recipe '{}' has empty template", name);
            assert!(!recipe.variables.is_empty(), "Recipe '{}' has no variables", name);
        }
    }

    #[tokio::test]
    async fn test_list_recipes_with_descriptions() {
        let (mgr, _project_dir, _user_dir) = make_two_dir_manager().await;

        let result = mgr.list_recipes_with_descriptions().await.unwrap();
        assert_eq!(result.len(), 3);
        for (name, desc) in &result {
            assert!(!name.is_empty());
            assert!(!desc.is_empty(), "Recipe '{}' has empty description", name);
        }
    }

    // -- two-directory resolution tests -------------------------------------

    #[tokio::test]
    async fn test_project_recipe_overrides_user_recipe() {
        let project_dir = tempfile::tempdir().unwrap();
        let user_dir = tempfile::tempdir().unwrap();

        // Write the same recipe to both dirs with different content
        let user_recipe = "---\ndescription: User version\n---\nUser template.\n";
        let project_recipe = "---\ndescription: Project version\n---\nProject template.\n";

        tokio::fs::write(user_dir.path().join("shared.md"), user_recipe).await.unwrap();
        tokio::fs::write(project_dir.path().join("shared.md"), project_recipe).await.unwrap();

        let mgr = RecipeManager::new(
            project_dir.path().to_path_buf(),
            user_dir.path().to_path_buf(),
        )
        .await
        .unwrap();

        let recipe = mgr.load_recipe("shared").await.unwrap();
        assert_eq!(recipe.description, "Project version", "Project should override user");
    }

    #[tokio::test]
    async fn test_user_recipe_loaded_as_fallback() {
        let project_dir = tempfile::tempdir().unwrap();
        let user_dir = tempfile::tempdir().unwrap();

        // Only write to user dir
        let user_recipe = "---\ndescription: User only\n---\nFallback.\n";
        tokio::fs::write(user_dir.path().join("fallback.md"), user_recipe).await.unwrap();

        let mgr = RecipeManager::new(
            project_dir.path().to_path_buf(),
            user_dir.path().to_path_buf(),
        )
        .await
        .unwrap();

        let recipe = mgr.load_recipe("fallback").await.unwrap();
        assert_eq!(recipe.description, "User only");
    }

    #[tokio::test]
    async fn test_list_recipes_merges_both_dirs() {
        let project_dir = tempfile::tempdir().unwrap();
        let user_dir = tempfile::tempdir().unwrap();

        let recipe = "---\ndescription: Test\n---\nTmpl.\n";
        tokio::fs::write(user_dir.path().join("user_only.md"), recipe).await.unwrap();
        tokio::fs::write(project_dir.path().join("project_only.md"), recipe).await.unwrap();
        // A shared recipe in both dirs — should only appear once
        tokio::fs::write(user_dir.path().join("shared.md"), recipe).await.unwrap();
        tokio::fs::write(project_dir.path().join("shared.md"), recipe).await.unwrap();

        let mgr = RecipeManager::new(
            project_dir.path().to_path_buf(),
            user_dir.path().to_path_buf(),
        )
        .await
        .unwrap();

        let recipes = mgr.list_recipes().await.unwrap();
        assert_eq!(recipes.len(), 3, "Should have 3 unique recipes: {:?}", recipes);
        assert!(recipes.contains(&"user_only".to_string()));
        assert!(recipes.contains(&"project_only".to_string()));
        assert!(recipes.contains(&"shared".to_string()));
    }
}
