//! Target specification and validation for documentation generation.

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

use anyhow::{bail, Result};
use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::error::MemexError;

/// Layout strategy per target.
#[derive(Clone, Debug)]
pub enum Layout {
    /// Generate one Markdown file per item, mirroring rustdoc's tree structure.
    ModuleTree,
    /// Collapse each module into a single page grouped by item type.
    ModuleSingle,
}

impl<'de> Deserialize<'de> for Layout {
    /// Parse a layout value from its string representation in configuration files.
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        match s.as_str() {
            "module-tree" => Ok(Layout::ModuleTree),
            "module-single" => Ok(Layout::ModuleSingle),
            other => {
                let err = MemexError::InvalidLayout {
                    layout: other.to_string(),
                };
                Err(serde::de::Error::custom(err))
            }
        }
    }
}

impl Serialize for Layout {
    /// Serialize the layout enum back into its TOML string form.
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let s = match self {
            Layout::ModuleTree => "module-tree",
            Layout::ModuleSingle => "module-single",
        };
        serializer.serialize_str(s)
    }
}

/// A configurable rendering target.
#[derive(Clone, Debug)]
pub struct TargetSpec {
    /// Absolute path to the rustdoc JSON file for this crate.
    pub json_path: PathBuf,
    /// Output root under `docs/src/` (e.g., "api/auth_flow").
    pub out_root: String,
    /// Layout strategy to use for this target.
    pub layout: Layout,
    /// Whether to include private items in the output.
    pub include_private: bool,
}

impl TargetSpec {
    /// Construct a validated target using the default `ModuleTree` layout.
    pub fn new<P, S>(json_path: P, out_root: S) -> Result<Self>
    where
        P: Into<PathBuf>,
        S: Into<String>,
    {
        let path = json_path.into();
        validate_json_path(&path)?;
        let out_root = normalize_out_root(out_root.into())?;
        Ok(Self {
            json_path: path,
            out_root,
            layout: Layout::ModuleTree,
            include_private: false,
        })
    }

    /// Override the layout strategy for this target.
    pub fn with_layout(mut self, layout: Layout) -> Self {
        self.layout = layout;
        self
    }

    /// Configure whether to include private items in the output.
    pub fn with_private_items(mut self, include: bool) -> Self {
        self.include_private = include;
        self
    }
}

/// Validate that targets reference real JSON files and write to unique out roots.
pub fn validate_targets(targets: &[TargetSpec]) -> Result<()> {
    let mut out_paths: Vec<PathBuf> = Vec::new();
    for target in targets {
        validate_json_path(&target.json_path)?;
        out_paths.push(PathBuf::from(&target.out_root));
    }

    // Detect overlapping output directories.
    out_paths.sort();
    for window in out_paths.windows(2) {
        if let [a, b] = window {
            if a == b {
                bail!("duplicate out_root detected: `{}`", a.display());
            }
            if b.starts_with(a) {
                bail!(
                    "out_root `{}` is nested under `{}`; choose distinct destinations",
                    b.display(),
                    a.display()
                );
            }
        }
    }

    Ok(())
}

/// Ensure the rustdoc JSON path exists, is a file, and uses the `.json` extension.
fn validate_json_path(path: &Path) -> Result<()> {
    if !path.exists() {
        bail!("rustdoc JSON not found at `{}`", path.display());
    }
    if !path.is_file() {
        bail!("rustdoc JSON path `{}` is not a file", path.display());
    }
    match path.extension().and_then(|e| e.to_str()) {
        Some("json") => Ok(()),
        _ => bail!(
            "rustdoc artifact `{}` must have a `.json` extension",
            path.display()
        ),
    }
}

/// Validate and clean the configured output directory path.
pub(crate) fn normalize_out_root(raw: String) -> Result<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        bail!("out_root cannot be empty");
    }

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

    let cleaned = trimmed.trim_matches('/').to_string();
    if cleaned.is_empty() {
        bail!("out_root cannot resolve to root");
    }

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

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

    Ok(normalized.to_string_lossy().into_owned())
}

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

    // normalize_out_root tests
    #[test]
    fn normalize_out_root_basic() {
        assert_eq!(normalize_out_root("api/foo".into()).unwrap(), "api/foo");
    }

    #[test]
    fn normalize_out_root_strips_trailing_slash() {
        assert_eq!(normalize_out_root("api/foo/".into()).unwrap(), "api/foo");
        assert_eq!(normalize_out_root("api/foo///".into()).unwrap(), "api/foo");
    }

    #[test]
    fn normalize_out_root_rejects_leading_slash() {
        // Leading slashes make the path absolute, which is rejected
        assert!(normalize_out_root("/api/foo".into()).is_err());
        assert!(normalize_out_root("///api/foo".into()).is_err());
        assert!(normalize_out_root("/api/foo/".into()).is_err());
    }

    #[test]
    fn normalize_out_root_rejects_parent_dir() {
        assert!(normalize_out_root("api/../etc".into()).is_err());
        assert!(normalize_out_root("../etc".into()).is_err());
        assert!(normalize_out_root("api/foo/../bar".into()).is_err());
    }

    #[test]
    fn normalize_out_root_handles_current_dir() {
        // Leading ./ is rejected (creates CurDir component)
        assert!(normalize_out_root("./api".into()).is_err());

        // Middle ./ segments are normalized away by Rust (safe)
        assert_eq!(normalize_out_root("api/./foo".into()).unwrap(), "api/foo");
    }

    #[test]
    fn normalize_out_root_rejects_empty() {
        assert!(normalize_out_root("".into()).is_err());
        assert!(normalize_out_root("   ".into()).is_err());
        assert!(normalize_out_root("/".into()).is_err());
        assert!(normalize_out_root("///".into()).is_err());
    }

    // validate_targets tests
    #[test]
    fn validate_targets_detects_duplicates() {
        use std::fs::File;
        use std::io::Write;

        // Create temporary JSON files
        let temp_dir = std::env::temp_dir();
        let json1 = temp_dir.join("test_dup1.json");
        let json2 = temp_dir.join("test_dup2.json");

        File::create(&json1).unwrap().write_all(b"{}").unwrap();
        File::create(&json2).unwrap().write_all(b"{}").unwrap();

        let targets = vec![
            TargetSpec {
                json_path: json1.clone(),
                out_root: "api/foo".into(),
                layout: Layout::ModuleTree,
                include_private: false,
            },
            TargetSpec {
                json_path: json2.clone(),
                out_root: "api/foo".into(), // duplicate
                layout: Layout::ModuleTree,
                include_private: false,
            },
        ];

        let result = validate_targets(&targets);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("duplicate"));

        // Cleanup
        let _ = std::fs::remove_file(json1);
        let _ = std::fs::remove_file(json2);
    }

    #[test]
    fn validate_targets_detects_nesting() {
        use std::fs::File;
        use std::io::Write;

        let temp_dir = std::env::temp_dir();
        let json1 = temp_dir.join("test_nest1.json");
        let json2 = temp_dir.join("test_nest2.json");

        File::create(&json1).unwrap().write_all(b"{}").unwrap();
        File::create(&json2).unwrap().write_all(b"{}").unwrap();

        let targets = vec![
            TargetSpec {
                json_path: json1.clone(),
                out_root: "api/foo".into(),
                layout: Layout::ModuleTree,
                include_private: false,
            },
            TargetSpec {
                json_path: json2.clone(),
                out_root: "api/foo/bar".into(), // nested
                layout: Layout::ModuleTree,
                include_private: false,
            },
        ];

        let result = validate_targets(&targets);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("nested"));

        // Cleanup
        let _ = std::fs::remove_file(json1);
        let _ = std::fs::remove_file(json2);
    }

    #[test]
    fn validate_targets_allows_siblings() {
        use std::fs::File;
        use std::io::Write;

        let temp_dir = std::env::temp_dir();
        let json1 = temp_dir.join("test_sib1.json");
        let json2 = temp_dir.join("test_sib2.json");

        File::create(&json1).unwrap().write_all(b"{}").unwrap();
        File::create(&json2).unwrap().write_all(b"{}").unwrap();

        let targets = vec![
            TargetSpec {
                json_path: json1.clone(),
                out_root: "api/foo".into(),
                layout: Layout::ModuleTree,
                include_private: false,
            },
            TargetSpec {
                json_path: json2.clone(),
                out_root: "api/bar".into(), // sibling, not nested
                layout: Layout::ModuleTree,
                include_private: false,
            },
        ];

        let result = validate_targets(&targets);
        assert!(result.is_ok());

        // Cleanup
        let _ = std::fs::remove_file(json1);
        let _ = std::fs::remove_file(json2);
    }

    // TargetSpec builder tests
    #[test]
    fn target_spec_defaults() {
        use std::fs::File;
        use std::io::Write;

        let temp_dir = std::env::temp_dir();
        let json = temp_dir.join("test_defaults.json");
        File::create(&json).unwrap().write_all(b"{}").unwrap();

        let spec = TargetSpec::new(&json, "api/foo").unwrap();

        assert_eq!(spec.json_path, json);
        assert_eq!(spec.out_root, "api/foo");
        assert!(matches!(spec.layout, Layout::ModuleTree));
        assert!(!spec.include_private);

        // Cleanup
        let _ = std::fs::remove_file(json);
    }

    #[test]
    fn target_spec_chained_builders() {
        use std::fs::File;
        use std::io::Write;

        let temp_dir = std::env::temp_dir();
        let json = temp_dir.join("test_chained.json");
        File::create(&json).unwrap().write_all(b"{}").unwrap();

        let spec = TargetSpec::new(&json, "api/foo")
            .unwrap()
            .with_layout(Layout::ModuleSingle)
            .with_private_items(true);

        assert!(matches!(spec.layout, Layout::ModuleSingle));
        assert!(spec.include_private);

        // Cleanup
        let _ = std::fs::remove_file(json);
    }

    #[test]
    fn target_spec_validates_json_path() {
        // Non-existent file should fail
        let result = TargetSpec::new("/tmp/nonexistent_12345.json", "api/foo");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not found"));
    }

    #[test]
    fn target_spec_validates_out_root() {
        use std::fs::File;
        use std::io::Write;

        let temp_dir = std::env::temp_dir();
        let json = temp_dir.join("test_validate.json");
        File::create(&json).unwrap().write_all(b"{}").unwrap();

        // Invalid out_root (parent dir traversal)
        let result = TargetSpec::new(&json, "api/../etc");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains(".."));

        // Invalid out_root (absolute path)
        let result = TargetSpec::new(&json, "/absolute/path");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("relative"));

        // Valid out_root with trailing slash (should be normalized)
        let spec = TargetSpec::new(&json, "api/foo/").unwrap();
        assert_eq!(spec.out_root, "api/foo");

        // Cleanup
        let _ = std::fs::remove_file(json);
    }
}
