//! Generate ignore files (.gitignore, .dockerignore, etc.)
//!
//! This crate provides a builder-based API for generating tool-specific ignore files
//! from a declarative configuration.
//!
//! # Example
//!
//! ```rust,no_run
//! use cuenv_ignore::{IgnoreFile, IgnoreFiles};
//!
//! let result = IgnoreFiles::builder()
//!     .directory(".")
//!     .file(IgnoreFile::new("git")
//!         .pattern("node_modules/")
//!         .pattern(".env"))
//!     .file(IgnoreFile::new("docker")
//!         .pattern("target/"))
//!     .generate()?;
//!
//! for file in &result.files {
//!     println!("{}: {}", file.status, file.filename);
//! }
//! # Ok::<(), cuenv_ignore::Error>(())
//! ```
//!
//! # Features
//!
//! - `serde`: Enable serde serialization/deserialization for configuration types

#![warn(missing_docs)]

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

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

/// A single ignore file configuration.
///
/// # Example
///
/// ```rust
/// use cuenv_ignore::IgnoreFile;
///
/// let file = IgnoreFile::new("git")
///     .pattern("node_modules/")
///     .pattern(".env")
///     .header("Generated by my-tool");
///
/// assert_eq!(file.output_filename(), ".gitignore");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct IgnoreFile {
    tool: String,
    patterns: Vec<String>,
    filename: Option<String>,
    header: Option<String>,
}

impl IgnoreFile {
    /// Create a new ignore file configuration for a tool.
    ///
    /// The tool name is used to generate the default filename as `.{tool}ignore`.
    ///
    /// # Example
    ///
    /// ```rust
    /// use cuenv_ignore::IgnoreFile;
    ///
    /// let file = IgnoreFile::new("git");
    /// assert_eq!(file.output_filename(), ".gitignore");
    ///
    /// let file = IgnoreFile::new("docker");
    /// assert_eq!(file.output_filename(), ".dockerignore");
    /// ```
    #[must_use]
    pub fn new(tool: impl Into<String>) -> Self {
        Self {
            tool: tool.into(),
            patterns: Vec::new(),
            filename: None,
            header: None,
        }
    }

    /// Add a single pattern to the ignore file.
    #[must_use]
    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
        self.patterns.push(pattern.into());
        self
    }

    /// Add multiple patterns to the ignore file.
    #[must_use]
    pub fn patterns(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.patterns.extend(patterns.into_iter().map(Into::into));
        self
    }

    /// Set a custom filename for the ignore file.
    ///
    /// If not set, defaults to `.{tool}ignore`.
    #[must_use]
    pub fn filename(mut self, filename: impl Into<String>) -> Self {
        self.filename = Some(filename.into());
        self
    }

    /// Optionally set a custom filename for the ignore file.
    #[must_use]
    pub fn filename_opt(mut self, filename: Option<impl Into<String>>) -> Self {
        self.filename = filename.map(Into::into);
        self
    }

    /// Set a header comment for the ignore file.
    ///
    /// The header will be added at the top of the file with `#` prefixes.
    /// Each line of the header will be prefixed with `# `.
    #[must_use]
    pub fn header(mut self, header: impl Into<String>) -> Self {
        self.header = Some(header.into());
        self
    }

    /// Get the output filename for this ignore file.
    ///
    /// Returns the custom filename if set, otherwise `.{tool}ignore`.
    #[must_use]
    pub fn output_filename(&self) -> String {
        self.filename
            .clone()
            .unwrap_or_else(|| format!(".{}ignore", self.tool))
    }

    /// Get the tool name.
    #[must_use]
    pub fn tool(&self) -> &str {
        &self.tool
    }

    /// Get the patterns.
    #[must_use]
    pub fn patterns_list(&self) -> &[String] {
        &self.patterns
    }

    /// Generate the file content.
    ///
    /// # Example
    ///
    /// ```rust
    /// use cuenv_ignore::IgnoreFile;
    ///
    /// let file = IgnoreFile::new("git")
    ///     .pattern("node_modules/")
    ///     .header("My header");
    ///
    /// let content = file.generate();
    /// assert!(content.contains("# My header"));
    /// assert!(content.contains("node_modules/"));
    /// ```
    #[must_use]
    pub fn generate(&self) -> String {
        let mut lines = Vec::new();

        // Add header if present
        if let Some(ref header) = self.header {
            for line in header.lines() {
                lines.push(format!("# {line}"));
            }
            lines.push(String::new());
        }

        // Add patterns
        lines.extend(self.patterns.iter().cloned());

        format!("{}\n", lines.join("\n"))
    }
}

/// Entry point for building and generating ignore files.
pub struct IgnoreFiles;

impl IgnoreFiles {
    /// Create a new builder for generating ignore files.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// use cuenv_ignore::{IgnoreFile, IgnoreFiles};
    ///
    /// let result = IgnoreFiles::builder()
    ///     .directory(".")
    ///     .file(IgnoreFile::new("git").pattern("*.log"))
    ///     .generate()?;
    /// # Ok::<(), cuenv_ignore::Error>(())
    /// ```
    #[must_use]
    pub fn builder() -> IgnoreFilesBuilder {
        IgnoreFilesBuilder::default()
    }
}

/// Builder for generating multiple ignore files.
///
/// # Example
///
/// ```rust,no_run
/// use cuenv_ignore::{IgnoreFile, IgnoreFiles};
///
/// let result = IgnoreFiles::builder()
///     .directory("/path/to/project")
///     .require_git_repo(true)
///     .dry_run(false)
///     .file(IgnoreFile::new("git")
///         .pattern("node_modules/")
///         .pattern(".env"))
///     .file(IgnoreFile::new("docker")
///         .pattern("target/"))
///     .generate()?;
/// # Ok::<(), cuenv_ignore::Error>(())
/// ```
#[derive(Debug, Default)]
pub struct IgnoreFilesBuilder {
    directory: Option<PathBuf>,
    files: Vec<IgnoreFile>,
    require_git_repo: bool,
    dry_run: bool,
}

impl IgnoreFilesBuilder {
    /// Set the directory where ignore files will be generated.
    ///
    /// Defaults to the current directory if not set.
    #[must_use]
    pub fn directory(mut self, dir: impl AsRef<Path>) -> Self {
        self.directory = Some(dir.as_ref().to_path_buf());
        self
    }

    /// Add a single ignore file configuration.
    #[must_use]
    pub fn file(mut self, file: IgnoreFile) -> Self {
        self.files.push(file);
        self
    }

    /// Add multiple ignore file configurations.
    #[must_use]
    pub fn files(mut self, files: impl IntoIterator<Item = IgnoreFile>) -> Self {
        self.files.extend(files);
        self
    }

    /// Require that the directory is within a Git repository.
    ///
    /// Defaults to `false`. When `true`, returns an error if the directory
    /// is not within a Git repository.
    #[must_use]
    pub fn require_git_repo(mut self, require: bool) -> Self {
        self.require_git_repo = require;
        self
    }

    /// Enable dry-run mode.
    ///
    /// When `true`, no files will be written. The result will indicate
    /// what would happen with `WouldCreate` and `WouldUpdate` statuses.
    #[must_use]
    pub fn dry_run(mut self, dry_run: bool) -> Self {
        self.dry_run = dry_run;
        self
    }

    /// Generate the ignore files.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - `require_git_repo` is true and the directory is not within a Git repository
    /// - A tool name contains invalid characters (path separators)
    /// - File I/O fails
    pub fn generate(self) -> Result<SyncResult> {
        let dir = self.directory.unwrap_or_else(|| PathBuf::from("."));

        tracing::info!("Starting ignore file generation");

        // Verify we're in a git repository if required
        if self.require_git_repo {
            verify_git_repository(&dir)?;
        }

        let mut results = Vec::new();

        // Sort files by tool name for deterministic output
        let mut sorted_files = self.files;
        sorted_files.sort_by(|a, b| a.tool.cmp(&b.tool));

        for file in sorted_files {
            // Skip empty pattern lists
            if file.patterns.is_empty() {
                tracing::debug!("Skipping tool '{}' - no patterns", file.tool);
                continue;
            }

            // Validate tool name
            validate_tool_name(&file.tool)?;

            // Get and validate filename
            let filename = file.output_filename();
            validate_filename(&filename)?;

            let filepath = dir.join(&filename);
            let content = file.generate();

            let (status, pattern_count) = if self.dry_run {
                let status = if filepath.exists() {
                    let existing = std::fs::read_to_string(&filepath)?;
                    if existing == content {
                        FileStatus::Unchanged
                    } else {
                        FileStatus::WouldUpdate
                    }
                } else {
                    FileStatus::WouldCreate
                };
                (status, file.patterns.len())
            } else {
                let status = write_ignore_file(&filepath, &content)?;
                (status, file.patterns.len())
            };

            tracing::info!(
                filename = %filename,
                status = %status,
                patterns = pattern_count,
                "Processed ignore file"
            );

            results.push(FileResult {
                filename,
                status,
                pattern_count,
            });
        }

        Ok(SyncResult { files: results })
    }
}

// ============================================================================
// Legacy API for backwards compatibility
// ============================================================================

/// Configuration for generating a single ignore file.
///
/// This is the legacy API. Consider using [`IgnoreFile`] with the builder pattern instead.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct IgnoreConfig {
    /// Tool name (e.g., "git", "docker", "npm").
    pub tool: String,
    /// List of patterns to include in the ignore file.
    pub patterns: Vec<String>,
    /// Optional filename override.
    pub filename: Option<String>,
}

/// Generate ignore files from the given configurations.
///
/// This is the legacy API. Consider using [`IgnoreFiles::builder()`] instead.
///
/// # Arguments
///
/// * `dir` - Directory where ignore files will be generated
/// * `configs` - List of ignore configurations
/// * `dry_run` - If true, don't write files, just report what would happen
///
/// # Errors
///
/// Returns an error if:
/// - The directory is not within a Git repository
/// - A tool name contains invalid characters
/// - File I/O fails
pub fn generate_ignore_files(
    dir: &Path,
    configs: Vec<IgnoreConfig>,
    dry_run: bool,
) -> Result<SyncResult> {
    let files: Vec<IgnoreFile> = configs
        .into_iter()
        .map(|c| {
            let mut file = IgnoreFile::new(c.tool).patterns(c.patterns);
            if let Some(filename) = c.filename {
                file = file.filename(filename);
            }
            file
        })
        .collect();

    IgnoreFiles::builder()
        .directory(dir)
        .require_git_repo(true) // Legacy API always required git
        .dry_run(dry_run)
        .files(files)
        .generate()
}

// ============================================================================
// Result types
// ============================================================================

/// Result of generating ignore files.
#[derive(Debug)]
pub struct SyncResult {
    /// Results for each file that was processed.
    pub files: Vec<FileResult>,
}

/// Result for a single ignore file.
#[derive(Debug)]
pub struct FileResult {
    /// The filename that was generated (e.g., ".gitignore").
    pub filename: String,
    /// The status of the file operation.
    pub status: FileStatus,
    /// Number of patterns in the file.
    pub pattern_count: usize,
}

/// Status of a file operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
    /// File was newly created.
    Created,
    /// File existed and was updated with new content.
    Updated,
    /// File existed and content was unchanged.
    Unchanged,
    /// Would be created (dry-run mode).
    WouldCreate,
    /// Would be updated (dry-run mode).
    WouldUpdate,
}

impl std::fmt::Display for FileStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Created => write!(f, "Created"),
            Self::Updated => write!(f, "Updated"),
            Self::Unchanged => write!(f, "Unchanged"),
            Self::WouldCreate => write!(f, "Would create"),
            Self::WouldUpdate => write!(f, "Would update"),
        }
    }
}

// ============================================================================
// Error types
// ============================================================================

/// Errors that can occur during ignore file generation.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// Invalid tool name (contains path separators or is empty).
    #[error("Invalid tool name '{name}': {reason}")]
    InvalidToolName {
        /// The invalid tool name.
        name: String,
        /// Reason why it's invalid.
        reason: String,
    },

    /// Not inside a Git repository.
    #[error("Must be run within a Git repository")]
    NotInGitRepo,

    /// Cannot operate in a bare repository.
    #[error("Cannot operate in a bare Git repository")]
    BareRepository,

    /// Target directory is outside the Git repository.
    #[error("Target directory must be within the Git repository")]
    OutsideGitRepo,

    /// IO error during file operations.
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}

/// Result type for ignore operations.
pub type Result<T> = std::result::Result<T, Error>;

// ============================================================================
// Internal helpers
// ============================================================================

/// Verify that the directory is within a Git repository.
fn verify_git_repository(dir: &Path) -> Result<()> {
    let repo = gix::discover(dir).map_err(|e| {
        tracing::debug!("Git discovery failed: {}", e);
        Error::NotInGitRepo
    })?;

    let git_root = repo.workdir().ok_or(Error::BareRepository)?;

    // Canonicalize paths for comparison
    let canonical_dir = std::fs::canonicalize(dir)?;
    let canonical_git = std::fs::canonicalize(git_root)?;

    if !canonical_dir.starts_with(&canonical_git) {
        return Err(Error::OutsideGitRepo);
    }

    tracing::debug!(
        git_root = %canonical_git.display(),
        target_dir = %canonical_dir.display(),
        "Verified directory is within Git repository"
    );

    Ok(())
}

/// Validate that a tool name doesn't contain path separators.
fn validate_tool_name(tool: &str) -> Result<()> {
    if tool.is_empty() {
        return Err(Error::InvalidToolName {
            name: tool.to_string(),
            reason: "tool name cannot be empty".to_string(),
        });
    }

    if tool.contains('/') || tool.contains('\\') {
        return Err(Error::InvalidToolName {
            name: tool.to_string(),
            reason: "tool name cannot contain path separators".to_string(),
        });
    }

    if tool.contains("..") {
        return Err(Error::InvalidToolName {
            name: tool.to_string(),
            reason: "tool name cannot contain parent directory references".to_string(),
        });
    }

    Ok(())
}

/// Validate that a filename doesn't contain path separators.
fn validate_filename(filename: &str) -> Result<()> {
    if filename.contains('/') || filename.contains('\\') {
        return Err(Error::InvalidToolName {
            name: filename.to_string(),
            reason: "filename cannot contain path separators".to_string(),
        });
    }

    if filename.contains("..") {
        return Err(Error::InvalidToolName {
            name: filename.to_string(),
            reason: "filename cannot contain parent directory references".to_string(),
        });
    }

    Ok(())
}

/// Write an ignore file and return the status.
fn write_ignore_file(filepath: &Path, content: &str) -> Result<FileStatus> {
    let status = if filepath.exists() {
        let existing = std::fs::read_to_string(filepath)?;
        if existing == content {
            return Ok(FileStatus::Unchanged);
        }
        FileStatus::Updated
    } else {
        FileStatus::Created
    };

    std::fs::write(filepath, content)?;
    Ok(status)
}

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

    #[test]
    fn test_ignore_file_new() {
        let file = IgnoreFile::new("git");
        assert_eq!(file.tool(), "git");
        assert!(file.patterns_list().is_empty());
        assert_eq!(file.output_filename(), ".gitignore");
    }

    #[test]
    fn test_ignore_file_builder() {
        let file = IgnoreFile::new("docker")
            .pattern("node_modules/")
            .pattern(".env")
            .filename(".mydockerignore")
            .header("My header");

        assert_eq!(file.tool(), "docker");
        assert_eq!(file.patterns_list(), &["node_modules/", ".env"]);
        assert_eq!(file.output_filename(), ".mydockerignore");
    }

    #[test]
    fn test_ignore_file_patterns() {
        let file = IgnoreFile::new("git").patterns(["a", "b", "c"]);
        assert_eq!(file.patterns_list(), &["a", "b", "c"]);
    }

    #[test]
    fn test_ignore_file_generate_no_header() {
        let file = IgnoreFile::new("git")
            .pattern("node_modules/")
            .pattern(".env");

        let content = file.generate();
        assert_eq!(content, "node_modules/\n.env\n");
    }

    #[test]
    fn test_ignore_file_generate_with_header() {
        let file = IgnoreFile::new("git")
            .pattern("node_modules/")
            .header("Generated by my-tool\nDo not edit");

        let content = file.generate();
        assert!(content.starts_with("# Generated by my-tool\n# Do not edit\n\n"));
        assert!(content.contains("node_modules/"));
    }

    #[test]
    fn test_output_filename_default() {
        assert_eq!(IgnoreFile::new("git").output_filename(), ".gitignore");
        assert_eq!(IgnoreFile::new("docker").output_filename(), ".dockerignore");
        assert_eq!(IgnoreFile::new("npm").output_filename(), ".npmignore");
    }

    #[test]
    fn test_output_filename_custom() {
        let file = IgnoreFile::new("git").filename(".my-gitignore");
        assert_eq!(file.output_filename(), ".my-gitignore");
    }

    #[test]
    fn test_validate_tool_name_valid() {
        assert!(validate_tool_name("git").is_ok());
        assert!(validate_tool_name("docker").is_ok());
        assert!(validate_tool_name("my-custom-tool").is_ok());
        assert!(validate_tool_name("tool_with_underscore").is_ok());
    }

    #[test]
    fn test_validate_tool_name_invalid() {
        assert!(validate_tool_name("").is_err());
        assert!(validate_tool_name("../etc").is_err());
        assert!(validate_tool_name("foo/bar").is_err());
        assert!(validate_tool_name("foo\\bar").is_err());
        assert!(validate_tool_name("..").is_err());
        assert!(validate_tool_name("foo..bar").is_err());
    }

    #[test]
    fn test_file_status_display() {
        assert_eq!(FileStatus::Created.to_string(), "Created");
        assert_eq!(FileStatus::Updated.to_string(), "Updated");
        assert_eq!(FileStatus::Unchanged.to_string(), "Unchanged");
        assert_eq!(FileStatus::WouldCreate.to_string(), "Would create");
        assert_eq!(FileStatus::WouldUpdate.to_string(), "Would update");
    }

    // Legacy API tests
    #[test]
    fn test_ignore_config_to_ignore_file() {
        let config = IgnoreConfig {
            tool: "git".to_string(),
            patterns: vec!["node_modules/".to_string()],
            filename: Some(".custom".to_string()),
        };

        let file = IgnoreFile::new(config.tool)
            .patterns(config.patterns)
            .filename_opt(config.filename);

        assert_eq!(file.tool(), "git");
        assert_eq!(file.output_filename(), ".custom");
    }
}
