use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::Deserialize;

use crate::{
    target::{normalize_out_root, validate_targets},
    Layout, TargetSpec,
};

/// Root configuration structure for `memex.toml`.
#[derive(Debug, Deserialize)]
pub struct RawConfig {
    /// List of documentation targets.
    #[serde(default)]
    pub targets: Vec<RawTarget>,

    /// Workspace configuration.
    #[serde(default)]
    pub workspace: RawWorkspace,

    /// Optional agent-documentation configuration.
    #[serde(default)]
    pub agents: Option<RawAgents>,

    /// Documentation root configuration.
    #[serde(default)]
    pub docs: RawDocs,

    /// Cargo invocation configuration.
    #[serde(default)]
    pub cargo: RawCargo,
}

/// Configuration for a single documentation target.
///
/// Maps to a `[[targets]]` entry in `memex.toml`.
#[derive(Debug, Deserialize)]
pub struct RawTarget {
    /// Crate name (must match `<crate>.json` filename).
    #[serde(rename = "crate")]
    pub crate_name: String,

    /// Output directory relative to `docs/src/`.
    pub out: String,

    /// Layout strategy: `"module-tree"` or `"module-single"`.
    #[serde(default = "default_layout")]
    pub layout: Layout,

    /// Include private items (default: false).
    #[serde(default)]
    pub include_private: Option<bool>,
}

/// Default layout when not specified in TOML.
fn default_layout() -> Layout {
    Layout::ModuleTree
}

/// Raw agent-documentation configuration parsed from `memex.toml`.
#[derive(Debug, Deserialize, Default)]
pub struct RawAgents {
    /// Directory containing agent-facing markdown sources.
    #[serde(default)]
    pub source_dir: Option<String>,

    /// Instruction template configuration.
    #[serde(default)]
    pub instructions: Option<RawAgentsInstructions>,

    /// Output toggles for generated bundles.
    #[serde(default)]
    pub output: Option<RawAgentsOutput>,

    /// Repository tree configuration.
    #[serde(default)]
    pub tree: Option<RawAgentsTree>,
}

/// Raw instructions configuration block.
#[derive(Debug, Deserialize, Default)]
pub struct RawAgentsInstructions {
    /// Optional template override for the assembled instructions markdown.
    #[serde(default)]
    pub template: Option<String>,

    /// Placeholder substitutions applied to the template.
    #[serde(default)]
    pub format: HashMap<String, String>,
}

/// Raw output toggles controlling optional sections.
#[derive(Debug, Deserialize, Default)]
pub struct RawAgentsOutput {
    /// Whether to include the per-file summary block in the bundle.
    #[serde(default)]
    pub include_summary: bool,

    /// Whether to include the repository tree snapshot.
    #[serde(default)]
    pub include_tree: bool,
}

/// Repository tree configuration.
#[derive(Debug, Deserialize, Default)]
pub struct RawAgentsTree {
    /// Additional gitignore-style patterns to exclude from the tree snapshot.
    #[serde(default)]
    pub exclude: Vec<String>,
}

/// Raw `[docs]` configuration read directly from TOML.
#[derive(Debug, Deserialize)]
pub struct RawDocs {
    /// Directory containing the mdBook sources (relative to the config file by default).
    #[serde(default = "default_docs_root")]
    pub root: String,
}

/// Default location for mdBook sources when `[docs]` is omitted.
fn default_docs_root() -> String {
    "docs/src".to_string()
}

impl Default for RawDocs {
    /// Return the default docs configuration pointing at `docs/src`.
    fn default() -> Self {
        Self {
            root: default_docs_root(),
        }
    }
}

/// Raw `[workspace]` configuration.
#[derive(Debug, Deserialize, Default)]
pub struct RawWorkspace {
    /// Optional override for the workspace root directory.
    #[serde(default)]
    pub root: Option<String>,
}

/// Root `[cargo]` configuration block.
#[derive(Debug, Deserialize, Default)]
pub struct RawCargo {
    /// Options forwarded to `cargo doc`.
    #[serde(default)]
    pub doc: RawCargoDoc,
}

/// Raw `[cargo.doc]` configuration parsed from TOML.
#[derive(Debug, Deserialize, Default)]
pub struct RawCargoDoc {
    /// Whether `cargo doc` should run for the entire workspace.
    #[serde(default)]
    pub workspace: Option<bool>,
    /// Whether to include private items in the generated documentation.
    #[serde(default)]
    pub document_private_items: Option<bool>,
    /// Whether to skip building dependencies when running `cargo doc`.
    #[serde(default)]
    pub no_deps: Option<bool>,
}

/// Fully parsed configuration returned by [`parse_config`].
#[derive(Debug)]
pub struct Config {
    /// Validated rustdoc targets that will be rendered into Markdown.
    pub targets: Vec<TargetSpec>,
    /// Normalized agent-documentation configuration, when enabled.
    pub agents: Option<AgentsConfig>,
    /// Absolute filesystem path to the mdBook source directory.
    pub docs_root: PathBuf,
    /// Display-friendly path to the docs root (typically workspace-relative).
    pub docs_root_display: String,
    /// Options controlling how `cargo doc` is invoked.
    pub cargo_doc: CargoDocConfig,
}

/// Normalized agent-documentation configuration.
#[derive(Debug, Clone)]
pub struct AgentsConfig {
    /// Absolute directory containing agent-authored markdown files.
    pub source_dir: PathBuf,
    /// Display path shown in rendered summaries for agent files.
    pub source_dir_display: String,
    /// Template and placeholder values applied to the rendered instructions.
    pub instructions: AgentsInstructions,
    /// Flags toggling optional sections in the agent bundle.
    pub output: AgentsOutput,
    /// Directory tree exclusion patterns for repository snapshots.
    pub tree: AgentsTreeConfig,
}

/// Instruction template metadata.
#[derive(Debug, Clone)]
pub struct AgentsInstructions {
    /// Optional handlebars-style template used to render instructions.
    pub template: Option<String>,
    /// Placeholder substitutions available to the template renderer.
    pub format: HashMap<String, String>,
}

/// Output toggles controlling optional sections for generated bundles.
#[derive(Debug, Clone)]
pub struct AgentsOutput {
    /// Whether to include the per-file summaries block in `AGENTS.md`.
    pub include_summary: bool,
    /// Whether to append the repository tree snapshot section.
    pub include_tree: bool,
}

/// Repository tree configuration.
#[derive(Debug, Clone)]
pub struct AgentsTreeConfig {
    /// Additional patterns to exclude from the repository tree snapshot.
    pub exclude: Vec<String>,
}

/// Cargo doc options parsed from configuration.
#[derive(Debug, Clone)]
pub struct CargoDocConfig {
    /// Run `cargo doc` across the entire workspace when true.
    pub workspace: bool,
    /// Include private items (`--document-private-items`) when true.
    pub document_private_items: bool,
    /// Omit dependency crates (`--no-deps`) when true.
    pub no_deps: bool,
}

/// Normalized configuration ready for validation after rustdoc generation.
#[derive(Debug, Clone)]
pub struct ConfigPlan {
    /// Absolute path where generated Markdown will be written.
    pub docs_root: PathBuf,
    /// Display-friendly docs path (typically workspace-relative).
    pub docs_root_display: String,
    /// `cargo doc` options to use before materializing targets.
    pub cargo_doc: CargoDocConfig,
    /// Planned targets awaiting rustdoc artifacts.
    pub targets: Vec<PlannedTarget>,
    /// Optional agent bundle plan derived from configuration.
    pub agents: Option<AgentsConfig>,
}

impl ConfigPlan {
    /// Validate rustdoc artifacts and produce a full [`Config`].
    pub fn materialize(self) -> Result<Config> {
        let mut targets = Vec::with_capacity(self.targets.len());
        for planned in self.targets {
            targets.push(planned.materialize()?);
        }
        validate_targets(&targets)?;
        Ok(Config {
            targets,
            agents: self.agents,
            docs_root: self.docs_root,
            docs_root_display: self.docs_root_display,
            cargo_doc: self.cargo_doc,
        })
    }
}

/// Target specification that does not require rustdoc artifacts to exist yet.
#[derive(Debug, Clone)]
pub struct PlannedTarget {
    json_path: PathBuf,
    out_root: String,
    layout: Layout,
    include_private: bool,
}

impl PlannedTarget {
    /// Create a planned target, normalizing the output directory and storing metadata.
    fn new<P: Into<PathBuf>, S: Into<String>>(
        json_path: P,
        out_root: S,
        layout: Layout,
        include_private: bool,
    ) -> Result<Self> {
        let normalized_out = normalize_out_root(out_root.into())?;
        Ok(Self {
            json_path: json_path.into(),
            out_root: normalized_out,
            layout,
            include_private,
        })
    }

    /// Convert the planned target into a [`TargetSpec`] after validation.
    fn materialize(self) -> Result<TargetSpec> {
        Ok(TargetSpec::new(self.json_path, self.out_root)?
            .with_layout(self.layout)
            .with_private_items(self.include_private))
    }
}

/// Parse `memex.toml` configuration into target specifications.
///
/// Converts TOML string into [`TargetSpec`] instances. JSON paths are resolved
/// relative to `rustdoc_json_dir`.
///
/// # Example
///
/// ```no_run
/// use memex::parse_targets_toml;
/// use std::path::Path;
///
/// # fn main() -> anyhow::Result<()> {
/// let toml = r#"
///     [[targets]]
///     crate = "my_crate"
///     out = "api/my_crate"
/// "#;
///
/// let targets = parse_targets_toml(
///     Path::new("."),
///     Path::new("docs"),
///     Path::new("target/doc"),
///     toml,
/// )?;
/// # Ok(())
/// # }
/// ```
pub fn parse_targets_toml(
    workspace_root: &Path,
    config_dir: &Path,
    rustdoc_json_dir: &Path,
    toml_str: &str,
) -> Result<Vec<TargetSpec>> {
    let plan = parse_config_plan(workspace_root, config_dir, rustdoc_json_dir, toml_str)?;
    plan.materialize().map(|cfg| cfg.targets)
}

/// Parse `memex.toml` into structured configuration.
pub fn parse_config(
    workspace_root: &Path,
    config_dir: &Path,
    rustdoc_json_dir: &Path,
    toml_str: &str,
) -> Result<Config> {
    parse_config_plan(workspace_root, config_dir, rustdoc_json_dir, toml_str)?.materialize()
}

/// Parse `memex.toml` into a plan that can be materialized after rustdoc runs.
pub fn parse_config_plan(
    workspace_root: &Path,
    config_dir: &Path,
    rustdoc_json_dir: &Path,
    toml_str: &str,
) -> Result<ConfigPlan> {
    let raw = parse_raw_config(toml_str)?;
    build_config_plan_from_raw(workspace_root, config_dir, rustdoc_json_dir, raw)
}

/// Parse the raw TOML without performing filesystem validation.
pub fn parse_raw_config(toml_str: &str) -> Result<RawConfig> {
    toml::from_str(toml_str).context("failed to parse memex.toml")
}

/// Transform the deserialized configuration into a plan referencing on-disk paths.
pub(crate) fn build_config_plan_from_raw(
    workspace_root: &Path,
    config_dir: &Path,
    rustdoc_json_dir: &Path,
    raw: RawConfig,
) -> Result<ConfigPlan> {
    let docs_root = resolve_path("docs.root", config_dir, &raw.docs.root)?;
    let docs_root_display = path_display(workspace_root, &docs_root);

    let mut targets = Vec::new();
    for t in raw.targets {
        let spec = PlannedTarget::new(
            rustdoc_json_dir.join(format!("{}.json", t.crate_name)),
            t.out,
            t.layout,
            t.include_private.unwrap_or(false),
        )?;
        targets.push(spec);
    }

    let agents = match raw.agents {
        Some(raw_agents) => Some(parse_agents_config(
            raw_agents,
            workspace_root,
            config_dir,
            &docs_root,
        )?),
        None => None,
    };

    let cargo_doc = normalize_cargo_doc(&raw.cargo.doc);

    Ok(ConfigPlan {
        targets,
        agents,
        docs_root,
        docs_root_display,
        cargo_doc,
    })
}

/// Normalize the optional `[agents]` configuration into absolute paths and toggles.
fn parse_agents_config(
    raw: RawAgents,
    workspace_root: &Path,
    config_dir: &Path,
    docs_root: &Path,
) -> Result<AgentsConfig> {
    let source_dir = match raw.source_dir {
        Some(dir) => resolve_path("agents.source_dir", config_dir, &dir)?,
        None => docs_root.join("agents"),
    };
    let source_dir_display = path_display(workspace_root, &source_dir);
    let instructions = raw.instructions.unwrap_or_default();
    let output = raw.output.unwrap_or_default();
    let tree = raw.tree.unwrap_or_default();
    let tree_excludes = tree
        .exclude
        .into_iter()
        .map(|pattern| pattern.trim().to_string())
        .filter(|pattern| !pattern.is_empty())
        .collect();

    Ok(AgentsConfig {
        source_dir,
        source_dir_display,
        instructions: AgentsInstructions {
            template: instructions.template,
            format: instructions.format,
        },
        output: AgentsOutput {
            include_summary: output.include_summary,
            include_tree: output.include_tree,
        },
        tree: AgentsTreeConfig {
            exclude: tree_excludes,
        },
    })
}

/// Ensure a relative path string is normalized and safe for joining.
fn normalize_relative_path(field: &str, value: &str) -> Result<PathBuf> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        bail!("{field} cannot be empty");
    }

    let path = Path::new(trimmed);
    if path.is_absolute() {
        bail!("{field} `{}` must be relative", trimmed);
    }

    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                bail!("{field} `{}` cannot contain `..` segments", trimmed)
            }
            Component::Normal(part) => normalized.push(part),
            Component::RootDir | Component::Prefix(_) => {
                bail!("{field} `{}` contains unsupported components", trimmed)
            }
        }
    }

    if normalized.as_os_str().is_empty() {
        bail!("{field} cannot resolve to root");
    }

    Ok(normalized)
}

/// Resolve a user-provided path against `base_dir`, validating absolutes when specified.
fn resolve_path(field: &str, base_dir: &Path, value: &str) -> Result<PathBuf> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        bail!("{field} cannot be empty");
    }

    let candidate = Path::new(trimmed);
    if candidate.is_absolute() {
        validate_absolute_path(field, candidate)?;
        Ok(candidate.to_path_buf())
    } else {
        let rel = normalize_relative_path(field, trimmed)?;
        Ok(base_dir.join(rel))
    }
}

/// Reject absolute paths that traverse upwards with `..` components.
fn validate_absolute_path(field: &str, path: &Path) -> Result<()> {
    for component in path.components() {
        if matches!(component, Component::ParentDir) {
            bail!("{field} `{}` cannot contain `..` segments", path.display());
        }
    }
    Ok(())
}

/// Expand raw `cargo doc` options into concrete defaults.
fn normalize_cargo_doc(raw: &RawCargoDoc) -> CargoDocConfig {
    CargoDocConfig {
        workspace: raw.workspace.unwrap_or(false),
        document_private_items: raw.document_private_items.unwrap_or(false),
        no_deps: raw.no_deps.unwrap_or(true),
    }
}

/// Determine the workspace root, defaulting to the configuration directory.
pub(crate) fn resolve_workspace_root(config_dir: &Path, raw: &RawWorkspace) -> Result<PathBuf> {
    match raw.root.as_deref() {
        Some(root) => resolve_path("workspace.root", config_dir, root),
        None => Ok(config_dir.to_path_buf()),
    }
}

impl From<&RawCargoDoc> for CargoDocConfig {
    /// Convert a borrowed `RawCargoDoc` into a concrete configuration.
    fn from(raw: &RawCargoDoc) -> Self {
        normalize_cargo_doc(raw)
    }
}

/// Produce a workspace-relative display string for an absolute path.
fn path_display(workspace_root: &Path, abs: &Path) -> String {
    if let Ok(rel) = abs.strip_prefix(workspace_root) {
        if rel.as_os_str().is_empty() {
            String::from(".")
        } else {
            rel.to_string_lossy().replace('\\', "/")
        }
    } else {
        abs.to_string_lossy().replace('\\', "/")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::{self, File};
    use std::io::Write;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn setup_workspace(crate_names: &[&str]) -> (TempDir, PathBuf, PathBuf) {
        let workspace = tempfile::tempdir().unwrap();
        let config_dir = workspace.path().to_path_buf();
        let rustdoc_dir = workspace.path().join("target/doc");
        fs::create_dir_all(&rustdoc_dir).unwrap();

        for name in crate_names {
            let json = rustdoc_dir.join(format!("{name}.json"));
            File::create(&json).unwrap().write_all(b"{}").unwrap();
        }

        (workspace, config_dir, rustdoc_dir)
    }

    #[test]
    fn parse_minimal_config() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["my_crate"]);
        let toml = r#"
            [[targets]]
            crate = "my_crate"
            out = "api/my_crate"
        "#;

        let result = parse_targets_toml(workspace.path(), &config_dir, &rustdoc_dir, toml);

        assert!(result.is_ok());
        let targets = result.unwrap();
        assert_eq!(targets.len(), 1);
        assert_eq!(targets[0].out_root, "api/my_crate");
        assert!(matches!(targets[0].layout, Layout::ModuleTree));
        assert!(!targets[0].include_private);
    }

    #[test]
    fn parse_with_module_single_layout() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["my_crate3"]);
        let toml = r#"
            [[targets]]
            crate = "my_crate3"
            out = "api/my_crate"
            layout = "module-single"
        "#;

        let result = parse_targets_toml(workspace.path(), &config_dir, &rustdoc_dir, toml);

        assert!(result.is_ok());
        let targets = result.unwrap();
        assert!(matches!(targets[0].layout, Layout::ModuleSingle));
    }

    #[test]
    fn parse_invalid_layout() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["my_crate4"]);
        let toml = r#"
            [[targets]]
            crate = "my_crate4"
            out = "api/my_crate"
            layout = "invalid-layout"
        "#;

        let result = parse_targets_toml(workspace.path(), &config_dir, &rustdoc_dir, toml);

        assert!(result.is_err());
        let err = result.unwrap_err();
        // Check the full error chain for our custom message
        let full_err = format!("{:?}", err);
        assert!(
            full_err.contains("unsupported layout") || full_err.contains("invalid-layout"),
            "Error chain did not contain expected message. Full error: {}",
            full_err
        );
    }

    #[test]
    fn parse_with_include_private_true() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["my_crate5"]);
        let toml = r#"
            [[targets]]
            crate = "my_crate5"
            out = "api/my_crate"
            include_private = true
        "#;

        let result = parse_targets_toml(workspace.path(), &config_dir, &rustdoc_dir, toml);

        assert!(result.is_ok());
        let targets = result.unwrap();
        assert!(targets[0].include_private);
    }

    #[test]
    fn parse_multiple_targets() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["crate1", "crate2"]);

        let toml = r#"
            [[targets]]
            crate = "crate1"
            out = "api/crate1"

            [[targets]]
            crate = "crate2"
            out = "api/crate2"
            layout = "module-single"
        "#;

        let result = parse_targets_toml(workspace.path(), &config_dir, &rustdoc_dir, toml);

        assert!(result.is_ok());
        let targets = result.unwrap();
        assert_eq!(targets.len(), 2);
        assert!(matches!(targets[0].layout, Layout::ModuleTree));
        assert!(matches!(targets[1].layout, Layout::ModuleSingle));
    }

    #[test]
    fn parse_docs_root_defaults() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["root_default"]);
        let toml = r#"
            [[targets]]
            crate = "root_default"
            out = "api/root_default"
        "#;

        let cfg = parse_config(workspace.path(), &config_dir, &rustdoc_dir, toml)
            .expect("config should parse");
        assert_eq!(cfg.docs_root, workspace.path().join("docs/src"));
        assert_eq!(cfg.docs_root_display, "docs/src");
        assert!(!cfg.cargo_doc.workspace);
        assert!(!cfg.cargo_doc.document_private_items);
        assert!(cfg.cargo_doc.no_deps);
    }

    #[test]
    fn parse_docs_root_custom() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["root_custom"]);
        let toml = r#"
            [docs]
            root = "content/docs"

            [[targets]]
            crate = "root_custom"
            out = "api/root_custom"
        "#;

        let cfg = parse_config(workspace.path(), &config_dir, &rustdoc_dir, toml)
            .expect("config should parse");
        assert_eq!(cfg.docs_root, workspace.path().join("content/docs"));
        assert_eq!(cfg.docs_root_display, "content/docs");
    }

    #[test]
    fn parse_agents_section_with_overrides() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["agents_overrides"]);
        let toml = r#"
            [agents]
            source_dir = "my_agents"

            [agents.instructions]
            template = "Hello {name}"

            [agents.instructions.format]
            name = "World"

            [agents.output]
            include_summary = false
            include_tree = true
        "#;

        let cfg = parse_config(workspace.path(), &config_dir, &rustdoc_dir, toml)
            .expect("agents config should parse");
        let agents = cfg.agents.expect("agents config expected");
        assert_eq!(agents.source_dir, workspace.path().join("my_agents"));
        assert_eq!(
            agents.instructions.template.as_deref(),
            Some("Hello {name}")
        );
        assert_eq!(
            agents.instructions.format.get("name").map(String::as_str),
            Some("World")
        );
        assert!(!agents.output.include_summary);
        assert!(agents.output.include_tree);
    }

    #[test]
    fn parse_agents_tree_excludes() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["agents_tree"]);
        let toml = r#"
            [agents]
            source_dir = "docs/src/agents"

            [agents.tree]
            exclude = ["foo/", " docs/src/generated "]

            [[targets]]
            crate = "agents_tree"
            out = "api/agents_tree"
        "#;

        let cfg = parse_config(workspace.path(), &config_dir, &rustdoc_dir, toml)
            .expect("config should parse");
        let agents = cfg.agents.expect("agents config expected");
        assert_eq!(
            agents.tree.exclude,
            vec!["foo/".to_string(), "docs/src/generated".to_string()]
        );
    }

    #[test]
    fn parse_agents_defaults_follow_docs_root() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["agents_default"]);
        let toml = r#"
            [docs]
            root = "content/docs"

            [[targets]]
            crate = "agents_default"
            out = "api/agents_default"

            [agents]
        "#;

        let cfg = parse_config(workspace.path(), &config_dir, &rustdoc_dir, toml)
            .expect("config should parse");
        let agents = cfg.agents.expect("agents config expected");
        assert_eq!(
            agents.source_dir,
            workspace.path().join("content/docs/agents")
        );
    }

    #[test]
    fn parse_cargo_doc_overrides() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["cargo_override"]);
        let toml = r#"
            [cargo.doc]
            workspace = true
            document_private_items = true
            no_deps = false

            [[targets]]
            crate = "cargo_override"
            out = "api/cargo_override"
        "#;

        let cfg = parse_config(workspace.path(), &config_dir, &rustdoc_dir, toml)
            .expect("config should parse");
        assert!(cfg.cargo_doc.workspace);
        assert!(cfg.cargo_doc.document_private_items);
        assert!(!cfg.cargo_doc.no_deps);
    }

    #[test]
    fn parse_agents_allows_absolute_source() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["agents_abs"]);
        let toml = format!(
            "[agents]\nsource_dir = \"{}\"\n",
            workspace.path().display()
        );

        let cfg = parse_config(workspace.path(), &config_dir, &rustdoc_dir, &toml)
            .expect("config should parse");
        let agents = cfg.agents.expect("agents config expected");
        assert_eq!(agents.source_dir, workspace.path().to_path_buf());
        assert_eq!(agents.source_dir_display, ".");
    }

    #[test]
    fn parse_malformed_toml() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["malformed"]);
        let toml = r#"
            [[targets]
            crate = "my_crate"  // Missing closing bracket
            out = "api/my_crate"
        "#;

        let result = parse_targets_toml(workspace.path(), &config_dir, &rustdoc_dir, toml);

        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("parse"));
    }

    #[test]
    fn parse_missing_required_fields() {
        let toml = r#"
            [[targets]]
            out = "api/my_crate"
            # Missing 'crate' field
        "#;

        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["missing"]);
        let result = parse_targets_toml(workspace.path(), &config_dir, &rustdoc_dir, toml);

        assert!(result.is_err());
    }

    #[test]
    fn config_plan_delays_json_validation() {
        let (workspace, config_dir, rustdoc_dir) = setup_workspace(&["plan_crate"]);
        let toml = r#"
            [[targets]]
            crate = "plan_crate"
            out = "api/plan_crate"
        "#;

        let plan = parse_config_plan(workspace.path(), &config_dir, &rustdoc_dir, toml).unwrap();
        let json_path = rustdoc_dir.join("plan_crate.json");
        std::fs::remove_file(&json_path).unwrap();

        let result = plan.materialize();
        assert!(result.is_err());
    }

    #[test]
    fn workspace_root_defaults_to_config_dir() {
        let dir = PathBuf::from("/tmp/workspace");
        let raw = RawWorkspace::default();
        let result = resolve_workspace_root(&dir, &raw).unwrap();
        assert_eq!(result, dir);
    }

    #[test]
    fn workspace_root_resolves_relative_path() {
        let dir = PathBuf::from("/tmp/workspace");
        let raw = RawWorkspace {
            root: Some("subdir".into()),
        };
        let result = resolve_workspace_root(&dir, &raw).unwrap();
        assert_eq!(result, dir.join("subdir"));
    }

    #[test]
    fn workspace_root_allows_absolute_path() {
        let dir = PathBuf::from("/tmp/workspace");
        let raw = RawWorkspace {
            root: Some("/opt/project".into()),
        };
        let result = resolve_workspace_root(&dir, &raw).unwrap();
        assert_eq!(result, PathBuf::from("/opt/project"));
    }
}
