// SPDX-License-Identifier: GPL-3.0-or-later

use serde::Deserialize;
use std::fmt;
use std::path::PathBuf;

/// Represents the application configuration with flattened structure.
#[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Main {
    #[serde(deserialize_with = "validate_schema_version")]
    pub schema: String,
    #[serde(default)]
    pub intercept: Intercept,
    #[serde(default)]
    pub compilers: Vec<Compiler>,
    #[serde(default)]
    pub sources: SourceFilter,
    #[serde(default)]
    pub duplicates: DuplicateFilter,
    #[serde(default)]
    pub format: Format,
    #[serde(default)]
    pub headers: Headers,
}

impl Default for Main {
    fn default() -> Self {
        Self {
            schema: String::from(SUPPORTED_SCHEMA_VERSION),
            intercept: Intercept::default(),
            compilers: vec![],
            sources: SourceFilter::default(),
            duplicates: DuplicateFilter::default(),
            format: Format::default(),
            headers: Headers::default(),
        }
    }
}

impl fmt::Display for Main {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "Configuration:")?;
        match serde_saphyr::to_string(self) {
            Ok(yaml_string) => {
                for line in yaml_string.lines() {
                    writeln!(f, "{}", line)?;
                }
                Ok(())
            }
            Err(_) => Err(fmt::Error),
        }
    }
}

/// Simplified intercept configuration with mode.
#[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "mode")]
pub enum Intercept {
    #[serde(rename = "wrapper")]
    Wrapper,
    #[serde(rename = "preload")]
    Preload,
}

/// The default intercept mode is varying based on the target operating system.
impl Default for Intercept {
    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "windows"))]
    fn default() -> Self {
        Intercept::Wrapper
    }

    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))]
    fn default() -> Self {
        Intercept::Preload
    }
}

/// Represents compiler configuration matching the YAML format.
///
/// `as_` holds the spelling the user wrote, verbatim. Resolving it onto a
/// compiler family is semantic analysis, not schema validation, so it happens
/// in the wiring layer (`modes`) against the semantic hint builder.
#[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Compiler {
    pub path: PathBuf,
    #[serde(rename = "as", skip_serializing_if = "Option::is_none")]
    pub as_: Option<String>,
    #[serde(default)]
    pub ignore: bool,
}

/// Action to take for files matching a directory rule
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DirectoryAction {
    Include,
    Exclude,
}

/// A rule that specifies how to handle files within a directory
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct DirectoryRule {
    pub path: PathBuf,
    pub action: DirectoryAction,
}

/// A rule that specifies how to handle files matching a filename glob pattern.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct FileRule {
    pub pattern: String,
    pub action: DirectoryAction,
}

/// Source filter configuration for controlling which files are included in the compilation database.
///
/// Uses directory-based rules and filename-pattern rules with order-based evaluation semantics:
///
/// 1. **Order-based evaluation**: For each source file, the *last* rule whose path prefix
///    matches determines inclusion/exclusion. Filename-pattern rules are evaluated the same
///    way, independently: the *last* rule whose glob matches determines inclusion/exclusion.
/// 2. **Empty directories/files list**: Interpreted as "include everything" (no filtering) for
///    that list.
/// 3. **No-match behavior**: If no rule matches a file, the file is *included*.
/// 4. **Path matching**: Simple prefix matching for directory rules, no normalization.
/// 5. **Case sensitivity**: Always case-sensitive on all platforms.
/// 6. **Path separators**: Platform-specific (`/` on Unix, `\` on Windows).
/// 7. **Symlinks**: No symlink resolution — match literal paths only.
/// 8. **Directory matching**: A rule matches both files directly in the directory and files in subdirectories.
/// 9. **Empty path/pattern fields**: Invalid — validation must fail.
/// 10. **Filename-pattern matching**: A pattern without a path separator matches the source
///     file's basename; a pattern containing a separator matches the full source path as it
///     appears in the entry.
/// 11. **Composition**: An entry is emitted only when both the directory rules and the
///     filename-pattern rules accept it (logical AND of the two independent verdicts).
///
/// **Important**: For matching to work correctly, rule paths/patterns should use the same
/// format as configured in `format.paths.file`. This consistency is the user's responsibility.
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct SourceFilter {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub directories: Vec<DirectoryRule>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub files: Vec<FileRule>,
}

/// Duplicate filter configuration matching the YAML format.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct DuplicateFilter {
    pub match_on: Vec<OutputFields>,
}

impl Default for DuplicateFilter {
    fn default() -> Self {
        Self { match_on: vec![OutputFields::Directory, OutputFields::File] }
    }
}

/// Represent the fields of the JSON compilation database record.
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum OutputFields {
    #[serde(rename = "directory")]
    Directory,
    #[serde(rename = "file")]
    File,
    #[serde(rename = "arguments")]
    Arguments,
    #[serde(rename = "command")]
    Command,
    #[serde(rename = "output")]
    Output,
}

/// Format configuration matching the YAML format.
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Format {
    #[serde(default)]
    pub paths: PathFormat,
    #[serde(default)]
    pub entries: EntryFormat,
    #[serde(default)]
    pub arguments: ArgumentsFormat,
}

/// Controls how the `arguments` field of each entry is assembled during
/// semantic analysis.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ArgumentsFormat {
    /// Replace `@file` response-file references with their tokenized contents.
    /// Disabled by default: an `@file` argument is recorded verbatim.
    #[serde(default)]
    pub from_response_files: bool,
    /// Fold compiler environment variables (e.g. `CPATH`) into each entry's
    /// arguments as explicit flags. Enabled by default.
    #[serde(default = "default_enabled")]
    pub from_environment: bool,
}

impl Default for ArgumentsFormat {
    fn default() -> Self {
        Self { from_response_files: false, from_environment: true }
    }
}

/// Format configuration of paths in the JSON compilation database.
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct PathFormat {
    #[serde(default)]
    pub directory: PathResolver,
    #[serde(default)]
    pub file: PathResolver,
}

/// Path resolver options matching the YAML format.
#[derive(Copy, Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum PathResolver {
    /// Leave the path as is without any transformation. (Default)
    #[default]
    #[serde(rename = "as-is")]
    AsIs,
    /// The path will be resolved to the canonical path.
    #[serde(rename = "canonical")]
    Canonical,
    /// The path will be resolved to the relative path to the directory attribute.
    #[serde(rename = "relative")]
    Relative,
    /// The path will be resolved to an absolute path.
    #[serde(rename = "absolute")]
    Absolute,
}

/// Configuration for formatting output entries matching the YAML format.
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct EntryFormat {
    #[serde(default = "default_enabled")]
    pub use_array_format: bool,
    #[serde(default = "default_enabled")]
    pub include_output_field: bool,
}

impl Default for EntryFormat {
    fn default() -> Self {
        Self { use_array_format: true, include_output_field: true }
    }
}

/// Configuration for synthesizing compilation database entries for header files.
///
/// Off by default: with `enabled: false` the output is unchanged. When enabled,
/// `strategy` selects how header files are discovered and which translation unit
/// donates the compile flags. Which file extensions count as headers is fixed
/// (a built-in C-family header set), not configurable here.
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Headers {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub strategy: HeaderStrategy,
}

/// Strategy for discovering header files and their donor translation unit.
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum HeaderStrategy {
    /// Clone from a compiled source in the same directory as the header.
    #[default]
    Siblings,
    /// Read the dependency files the build already emitted.
    DependencyFiles,
}

pub(crate) const SUPPORTED_SCHEMA_VERSION: &str = "4.2";

fn default_enabled() -> bool {
    true
}

// Custom deserialization function to validate the schema version
fn validate_schema_version<'de, D>(deserializer: D) -> Result<String, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let schema: String = Deserialize::deserialize(deserializer)?;
    if schema != SUPPORTED_SCHEMA_VERSION {
        use serde::de::Error;
        Err(Error::custom(format!(
            "Unsupported schema version: {schema}. Expected: {SUPPORTED_SCHEMA_VERSION}"
        )))
    } else {
        Ok(schema)
    }
}

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

    #[test]
    fn test_header_strategy_default_is_siblings() {
        let sut = HeaderStrategy::default();

        assert_eq!(sut, HeaderStrategy::Siblings);
    }

    #[test]
    fn test_headers_default_is_disabled_with_siblings_strategy() {
        let sut = Headers::default();

        assert!(!sut.enabled, "headers should be disabled by default");
        assert_eq!(sut.strategy, HeaderStrategy::Siblings);
    }

    #[test]
    fn test_header_strategy_deserializes_kebab_case_variants() {
        let cases =
            [("siblings", HeaderStrategy::Siblings), ("dependency-files", HeaderStrategy::DependencyFiles)];

        for (input, expected) in cases {
            let yaml = format!("strategy: {input}");

            let sut: Headers = serde_saphyr::from_str(&yaml).unwrap();

            assert_eq!(sut.strategy, expected, "case: strategy: {input}");
        }
    }

    #[test]
    fn test_header_strategy_rejects_unknown_variant() {
        let yaml = "strategy: unknown";

        let sut: Result<Headers, _> = serde_saphyr::from_str(yaml);

        assert!(sut.is_err(), "expected deserialization to fail for: {yaml}");
    }

    #[test]
    fn compiler_as_spelling_is_kept_verbatim() {
        // The schema layer validates nothing about `as:`: it round-trips the
        // spelling the user wrote, whatever it is.
        let cases = ["gcc", "ccache", "not-a-compiler"];

        for spelling in cases {
            let yaml = format!("path: /usr/bin/cc\nas: {spelling}");

            let sut: Compiler = serde_saphyr::from_str(&yaml).unwrap();

            assert_eq!(sut.as_.as_deref(), Some(spelling), "case: as: {spelling}");
        }
    }
}
