//! Core license analysis functionality and types

use crate::spdx;
use ignore::WalkBuilder;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use toml::Value as TomlValue;

use crate::cache;
use crate::cli;
use crate::config;
use crate::debug::{log, log_debug, log_error, FeludaResult, LogLevel};

static GITHUB_TOKEN: OnceLock<Option<String>> = OnceLock::new();

/// Set the GitHub API token for authenticated requests
pub fn set_github_token(token: Option<String>) {
    let _ = GITHUB_TOKEN.set(token);
}

/// Get the GitHub API token if set
fn get_github_token() -> Option<&'static str> {
    GITHUB_TOKEN.get().and_then(|t| t.as_deref())
}

/// License compatibility enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LicenseCompatibility {
    Compatible,
    Incompatible,
    Unknown,
}

impl std::fmt::Display for LicenseCompatibility {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Compatible => write!(f, "Compatible"),
            Self::Incompatible => write!(f, "Incompatible"),
            Self::Unknown => write!(f, "Unknown"),
        }
    }
}

/// Structure for deserializing license compatibility matrix from TOML
#[derive(Deserialize, Debug, Clone)]
struct LicenseCompatibilityMatrix {
    #[serde(rename = "MIT")]
    mit: Option<LicenseEntry>,
    #[serde(rename = "Apache-2_0")]
    apache_2_0: Option<LicenseEntry>,
    #[serde(rename = "GPL-3_0")]
    gpl_3_0: Option<LicenseEntry>,
    #[serde(rename = "GPL-2_0")]
    gpl_2_0: Option<LicenseEntry>,
    #[serde(rename = "AGPL-3_0")]
    agpl_3_0: Option<LicenseEntry>,
    #[serde(rename = "LGPL-3_0")]
    lgpl_3_0: Option<LicenseEntry>,
    #[serde(rename = "LGPL-2_1")]
    lgpl_2_1: Option<LicenseEntry>,
    #[serde(rename = "MPL-2_0")]
    mpl_2_0: Option<LicenseEntry>,
    #[serde(rename = "BSD-3-Clause")]
    bsd_3_clause: Option<LicenseEntry>,
    #[serde(rename = "BSD-2-Clause")]
    bsd_2_clause: Option<LicenseEntry>,
    #[serde(rename = "ISC")]
    isc: Option<LicenseEntry>,
    #[serde(rename = "_0BSD")]
    bsd_0: Option<LicenseEntry>,
    #[serde(rename = "Unlicense")]
    unlicense: Option<LicenseEntry>,
    #[serde(rename = "WTFPL")]
    wtfpl: Option<LicenseEntry>,
}

#[derive(Deserialize, Debug, Clone)]
struct LicenseEntry {
    compatible_with: Vec<String>,
}

/// Static cache for the compatibility matrix
#[cfg(not(test))]
static COMPATIBILITY_MATRIX: OnceLock<HashMap<String, Vec<String>>> = OnceLock::new();

/// OSI license status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OsiStatus {
    Approved,
    NotApproved,
    Unknown,
}

impl std::fmt::Display for OsiStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Approved => write!(f, "approved"),
            Self::NotApproved => write!(f, "not-approved"),
            Self::Unknown => write!(f, "unknown"),
        }
    }
}

/// OSI license information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsiLicenseInfo {
    pub id: String,
    pub name: String,
    pub status: OsiStatus,
}

/// License Info of dependencies
#[derive(Serialize, Debug, Clone)]
pub struct LicenseInfo {
    pub name: String,                        // The name of the software or library
    pub version: String,                     // The version of the software or library
    pub license: Option<String>, // An optional field that contains the license type (e.g., MIT, Apache 2.0)
    pub is_restrictive: bool,    // A boolean indicating whether the license is restrictive or not
    pub compatibility: LicenseCompatibility, // Compatibility with project license
    pub osi_status: OsiStatus,   // OSI approval status
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sub_project: Option<String>, // Workspace member that brought in this dependency (None for non-monorepos)
}

impl LicenseInfo {
    pub fn get_license(&self) -> String {
        match &self.license {
            Some(license_name) => String::from(license_name),
            None => String::from("No License"),
        }
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn version(&self) -> &str {
        &self.version
    }

    pub fn is_restrictive(&self) -> &bool {
        &self.is_restrictive
    }

    pub fn compatibility(&self) -> &LicenseCompatibility {
        &self.compatibility
    }

    pub fn osi_status(&self) -> &OsiStatus {
        &self.osi_status
    }

    pub fn sub_project(&self) -> Option<&str> {
        self.sub_project.as_deref()
    }

    #[allow(dead_code)]
    pub fn osi_info(&self) -> Option<OsiLicenseInfo> {
        self.license.as_ref().map(|license| OsiLicenseInfo {
            id: license.clone(),
            name: license.clone(),
            status: self.osi_status,
        })
    }
}

/// License Info structure for GitHub API data
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct License {
    pub title: String,            // The full name of the license
    pub spdx_id: String,          // The SPDX identifier for the license
    pub permissions: Vec<String>, // A list of permissions granted by the license
    pub conditions: Vec<String>,  // A list of conditions that must be met under the license
    pub limitations: Vec<String>, // A list of limitations imposed by the license
}

/// Fetch license data from GitHub's official Licenses API
/// Attempts to load from cache first, falls back to GitHub API if cache miss or stale
pub fn fetch_licenses_from_github() -> FeludaResult<HashMap<String, License>> {
    log(LogLevel::Info, "Fetching licenses from GitHub Licenses API");

    match cache::load_github_licenses_from_cache() {
        Ok(Some(cached_licenses)) => {
            log(
                LogLevel::Info,
                &format!("Using cached licenses ({})", cached_licenses.len()),
            );
            return Ok(cached_licenses);
        }
        Ok(None) => {
            log(LogLevel::Info, "Cache miss or stale, fetching from GitHub");
        }
        Err(e) => {
            log(
                LogLevel::Warn,
                &format!("Cache read error: {e}, fetching from GitHub"),
            );
        }
    }

    let licenses_map = cli::with_spinner("Fetching licenses from GitHub API", |indicator| {
        let rt = match tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
        {
            Ok(rt) => rt,
            Err(err) => {
                log_error("Failed to create tokio runtime", &err);
                return HashMap::new();
            }
        };

        rt.block_on(fetch_licenses_concurrent(indicator))
    });

    if !licenses_map.is_empty() {
        if let Err(e) = cache::save_github_licenses_to_cache(&licenses_map) {
            log(LogLevel::Warn, &format!("Failed to save cache: {e}"));
        }
    } else {
        log(
            LogLevel::Warn,
            "No licenses fetched from GitHub API, cache not saved",
        );
    }

    Ok(licenses_map)
}

/// Async helper function for concurrent license fetching with rate limiting
async fn fetch_licenses_concurrent(
    indicator: &crate::cli::LoadingIndicator,
) -> HashMap<String, License> {
    let mut licenses_map = HashMap::new();

    // Create async HTTP client with optional authentication
    let mut client_builder = reqwest::Client::builder()
        .user_agent("feluda-license-checker/1.0")
        .timeout(Duration::from_secs(30));

    if let Some(token) = get_github_token() {
        log(
            LogLevel::Info,
            "Using authenticated GitHub API requests (higher rate limits)",
        );
        let mut headers = reqwest::header::HeaderMap::new();
        headers.insert(
            reqwest::header::AUTHORIZATION,
            format!("Bearer {token}")
                .parse()
                .expect("Invalid token format"),
        );
        client_builder = client_builder.default_headers(headers);
    }

    let client = match client_builder.build() {
        Ok(client) => client,
        Err(err) => {
            log_error("Failed to create HTTP client", &err);
            return licenses_map;
        }
    };

    indicator.update_progress("fetching license list");

    // First, get the list of available licenses
    let licenses_list_url = "https://api.github.com/licenses";
    let response = match client.get(licenses_list_url).send().await {
        Ok(response) => response,
        Err(err) => {
            log_error("Failed to fetch licenses list from GitHub API", &err);
            return licenses_map;
        }
    };

    if !response.status().is_success() {
        log(
            LogLevel::Error,
            &format!("GitHub API returned error status: {}", response.status()),
        );
        return licenses_map;
    }

    let licenses_list: Vec<serde_json::Value> = match response.json().await {
        Ok(list) => list,
        Err(err) => {
            log_error("Failed to parse licenses list JSON", &err);
            return licenses_map;
        }
    };

    let total_licenses = licenses_list.len();
    indicator.update_progress(&format!("found {total_licenses} licenses"));

    let client = Arc::new(client);

    let license_keys: Vec<String> = licenses_list
        .iter()
        .filter_map(|license_info| {
            license_info
                .get("key")
                .and_then(|k| k.as_str())
                .map(|s| s.to_string())
        })
        .collect();

    // H2 multiplexes all requests over a single connection; no throttling needed for ~13 licenses
    let mut join_set = tokio::task::JoinSet::new();

    for license_key in license_keys {
        let client = Arc::clone(&client);

        join_set.spawn(async move {
            log(
                LogLevel::Info,
                &format!("Fetching detailed license info: {license_key}"),
            );

            let license_url = format!("https://api.github.com/licenses/{license_key}");

            match client.get(&license_url).send().await {
                Ok(license_response) => {
                    if license_response.status().is_success() {
                        match license_response.json::<serde_json::Value>().await {
                            Ok(license_data) => {
                                // Extract the license information we need
                                let title = license_data
                                    .get("name")
                                    .and_then(|n| n.as_str())
                                    .unwrap_or(&license_key)
                                    .to_string();

                                let spdx_id = license_data
                                    .get("spdx_id")
                                    .and_then(|s| s.as_str())
                                    .unwrap_or(&license_key)
                                    .to_string();

                                let permissions = license_data
                                    .get("permissions")
                                    .and_then(|p| p.as_array())
                                    .map(|arr| {
                                        arr.iter()
                                            .filter_map(|v| v.as_str())
                                            .map(String::from)
                                            .collect()
                                    })
                                    .unwrap_or_default();

                                let conditions = license_data
                                    .get("conditions")
                                    .and_then(|c| c.as_array())
                                    .map(|arr| {
                                        arr.iter()
                                            .filter_map(|v| v.as_str())
                                            .map(String::from)
                                            .collect()
                                    })
                                    .unwrap_or_default();

                                let limitations = license_data
                                    .get("limitations")
                                    .and_then(|l| l.as_array())
                                    .map(|arr| {
                                        arr.iter()
                                            .filter_map(|v| v.as_str())
                                            .map(String::from)
                                            .collect()
                                    })
                                    .unwrap_or_default();

                                let license = License {
                                    title,
                                    spdx_id,
                                    permissions,
                                    conditions,
                                    limitations,
                                };

                                // Use the SPDX ID as the key for consistency
                                let key_to_use = license_data
                                    .get("spdx_id")
                                    .and_then(|s| s.as_str())
                                    .unwrap_or(&license_key);

                                log(
                                    LogLevel::Info,
                                    &format!("Successfully processed license: {key_to_use}"),
                                );

                                Some((key_to_use.to_string(), license))
                            }
                            Err(err) => {
                                log_error(
                                    &format!("Failed to parse license JSON for {license_key}"),
                                    &err,
                                );
                                None
                            }
                        }
                    } else {
                        log(
                            LogLevel::Error,
                            &format!(
                                "Failed to fetch license {}: HTTP {}",
                                license_key,
                                license_response.status()
                            ),
                        );
                        None
                    }
                }
                Err(err) => {
                    log_error(
                        &format!("Failed to fetch license details for {license_key}"),
                        &err,
                    );
                    None
                }
            }
        });
    }

    let mut license_count = 0;
    while let Some(result) = join_set.join_next().await {
        match result {
            Ok(Some((key, license))) => {
                licenses_map.insert(key, license);
                license_count += 1;
            }
            Ok(None) => {}
            Err(e) => log(
                LogLevel::Error,
                &format!("License fetch task panicked: {e}"),
            ),
        }
        indicator.update_progress(&format!("fetched {license_count}/{total_licenses}"));
    }

    log(
        LogLevel::Info,
        &format!("Fetched {license_count} licenses from GitHub API"),
    );

    licenses_map
}

/// Static cache for OSI approved licenses
#[cfg(not(test))]
static OSI_LICENSES: OnceLock<HashMap<String, OsiStatus>> = OnceLock::new();

/// Fetch OSI approved licenses from official API (single request, no async needed)
pub fn fetch_osi_licenses() -> FeludaResult<HashMap<String, OsiStatus>> {
    log(LogLevel::Info, "Fetching OSI approved licenses");

    let osi_map = cli::with_spinner("Fetching OSI approved licenses", |indicator| {
        let client = match reqwest::blocking::Client::builder()
            .user_agent("feluda-license-checker/1.0")
            .timeout(Duration::from_secs(30))
            .build()
        {
            Ok(client) => client,
            Err(err) => {
                log_error("Failed to create HTTP client", &err);
                return HashMap::new();
            }
        };

        indicator.update_progress("fetching OSI licenses");

        let response = match client.get("https://api.opensource.org/licenses/").send() {
            Ok(response) => response,
            Err(err) => {
                log_error("Failed to fetch OSI licenses from API", &err);
                return HashMap::new();
            }
        };

        if !response.status().is_success() {
            log(
                LogLevel::Error,
                &format!("OSI API returned error status: {}", response.status()),
            );
            return HashMap::new();
        }

        let osi_licenses: Vec<serde_json::Value> = match response.json() {
            Ok(licenses) => licenses,
            Err(err) => {
                log_error("Failed to parse OSI licenses JSON", &err);
                return HashMap::new();
            }
        };

        let total_licenses = osi_licenses.len();
        indicator.update_progress(&format!("found {total_licenses} OSI licenses"));

        let mut osi_map = HashMap::new();
        for license_data in osi_licenses {
            if let Some(id) = license_data.get("id").and_then(|id| id.as_str()) {
                osi_map.insert(id.to_string(), OsiStatus::Approved);
            }
        }

        indicator.update_progress(&format!("processed {total_licenses} OSI licenses"));
        log(
            LogLevel::Info,
            &format!("Fetched {total_licenses} OSI approved licenses"),
        );

        osi_map
    });

    Ok(osi_map)
}

/// Get the OSI licenses map, loading it if not already cached
fn get_osi_licenses() -> &'static HashMap<String, OsiStatus> {
    #[cfg(not(test))]
    {
        OSI_LICENSES.get_or_init(|| {
            fetch_osi_licenses().unwrap_or_else(|e| {
                log(LogLevel::Warn, &format!("Failed to load OSI licenses: {e}"));
                log(LogLevel::Warn, "Continuing without OSI license information");
                HashMap::new()
            })
        })
    }

    #[cfg(test)]
    {
        use std::cell::RefCell;
        thread_local! {
            static OSI_MAP: RefCell<Option<HashMap<String, OsiStatus>>> = const { RefCell::new(None) };
        }

        OSI_MAP.with(|m| {
            let mut map = m.borrow_mut();
            if map.is_none() {
                match fetch_osi_licenses() {
                    Ok(loaded_map) => {
                        *map = Some(loaded_map);
                    }
                    Err(_) => {
                        *map = Some(HashMap::new());
                    }
                }
            }

            // Leak the memory to get a static reference (only for tests)
            let leaked: &'static HashMap<String, OsiStatus> =
                Box::leak(Box::new(map.as_ref().unwrap().clone()));
            leaked
        })
    }
}

/// Check OSI approval status for a license ID (single, non-compound).
fn get_osi_status_single(license_id: &str) -> OsiStatus {
    let normalized_id = normalize_license_id(license_id);
    let osi_licenses = get_osi_licenses();

    if let Some(status) = osi_licenses.get(&normalized_id) {
        return *status;
    }

    if let Some(status) = osi_licenses.get(license_id) {
        return *status;
    }

    match normalized_id.as_str() {
        "MIT" | "Apache-2.0" | "BSD-3-Clause" | "BSD-2-Clause" | "GPL-3.0" | "GPL-2.0"
        | "LGPL-3.0" | "LGPL-2.1" | "MPL-2.0" | "ISC" | "0BSD" => OsiStatus::Approved,
        "No License" => OsiStatus::NotApproved,
        _ => OsiStatus::Unknown,
    }
}

/// Check OSI approval status for a license string, which may be a compound SPDX expression.
pub fn get_osi_status(license_id: &str) -> OsiStatus {
    if spdx::is_compound(license_id) {
        let expr = spdx::parse(license_id);
        return spdx::expression_osi_status(&expr, &get_osi_status_single);
    }
    get_osi_status_single(license_id)
}

/// Check if a single (non-compound) license ID is restrictive.
fn is_single_license_restrictive(
    license_str: &str,
    known_licenses: &HashMap<String, License>,
    config: &config::FeludaConfig,
    strict: bool,
) -> bool {
    // Registry keys are bare ids (`GPL-2.0`), so strip an SPDX `-only`/`-or-later`/`+`
    // modifier before the fallback lookup — suffixed ids must classify like their base
    // license (`GPL-2.0-or-later` is exactly as copyleft as `GPL-2.0`).
    let registry_entry = known_licenses.get(license_str).or_else(|| {
        known_licenses.get(
            license_str
                .trim_end_matches('+')
                .trim_end_matches("-only")
                .trim_end_matches("-or-later"),
        )
    });
    if let Some(license_data) = registry_entry {
        // Match against GitHub/choosealicense.com's own `conditions` vocabulary. These keys must
        // be spelled exactly as the API emits them — the correct key is `disclose-source`, NOT
        // `source-disclosure` (a non-existent key that silently matched nothing, so copyleft
        // licenses present in the registry were classified as non-restrictive; issue #31):
        //   - `disclose-source`        → strong copyleft source disclosure (GPL family)
        //   - `network-use-disclosure` → network/SaaS copyleft (AGPL)
        //   - `same-license`           → share-alike / weak copyleft (LGPL, MPL, EPL); strict only
        let restrictive_conditions: &[&str] = if strict {
            &["disclose-source", "network-use-disclosure", "same-license"]
        } else {
            &["disclose-source", "network-use-disclosure"]
        };
        return restrictive_conditions
            .iter()
            .any(|&c| license_data.conditions.iter().any(|cond| cond == c));
    }

    let is_restrictive = config
        .licenses
        .restrictive
        .iter()
        .any(|r| license_str.contains(r.as_str()));

    if !is_restrictive && strict && license_str.contains("Unknown") {
        return true;
    }

    is_restrictive
}

/// Check if a license is considered restrictive based on configuration and known licenses.
///
/// Handles compound SPDX expressions:
///   - `OR`  → not restrictive if ANY alternative is permissive.
///   - `AND` → restrictive if ANY component is restrictive.
pub fn is_license_restrictive(
    license: &Option<String>,
    known_licenses: &HashMap<String, License>,
    strict: bool,
) -> bool {
    log(
        LogLevel::Info,
        &format!("Checking if license is restrictive: {license:?} (strict={strict})"),
    );

    let config = match config::load_config() {
        Ok(cfg) => cfg,
        Err(e) => {
            log_error("Error loading configuration", &e);
            config::FeludaConfig::default()
        }
    };

    if license.as_deref() == Some("No License") {
        log(
            LogLevel::Warn,
            "No license specified, considering as restrictive",
        );
        return true;
    }

    if let Some(license_str) = license {
        log_debug(
            "Checking against known licenses",
            &known_licenses.keys().collect::<Vec<_>>(),
        );

        if spdx::is_compound(license_str) {
            let expr = spdx::parse(license_str);
            let result = spdx::expression_is_restrictive(&expr, &|id| {
                is_single_license_restrictive(id, known_licenses, &config, strict)
            });
            log(
                LogLevel::Info,
                &format!("Compound expression '{license_str}' is_restrictive={result}"),
            );
            return result;
        }

        let result = is_single_license_restrictive(license_str, known_licenses, &config, strict);
        if result {
            log(
                LogLevel::Warn,
                &format!("License {license_str} is restrictive"),
            );
        } else {
            log(
                LogLevel::Info,
                &format!("License {license_str} is not restrictive"),
            );
        }
        return result;
    }

    if strict {
        log(
            LogLevel::Warn,
            "No license information available in strict mode, considering restrictive",
        );
        return true;
    }

    log(LogLevel::Warn, "No license information available");
    false
}

/// Check if a license should be ignored from analysis
///
/// Returns true if the license is in the ignore list configured in `.feluda.toml`
/// or via `FELUDA_LICENSES_IGNORE` environment variable.
pub fn is_license_ignored(license: Option<&str>) -> bool {
    log(
        LogLevel::Info,
        &format!("Checking if license should be ignored: {license:?}"),
    );

    let config = match config::load_config() {
        Ok(cfg) => {
            log(LogLevel::Info, "Successfully loaded configuration");
            cfg
        }
        Err(e) => {
            log_error("Error loading configuration", &e);
            log(LogLevel::Warn, "Using default configuration");
            config::FeludaConfig::default()
        }
    };

    if let Some(license_str) = license {
        let is_ignored = config
            .licenses
            .ignore
            .iter()
            .any(|ignore_license| license_str.contains(ignore_license));

        if is_ignored {
            log(
                LogLevel::Info,
                &format!("License {license_str} matches ignore pattern in config"),
            );
        } else {
            log(
                LogLevel::Info,
                &format!("License {license_str} does not match any ignore pattern"),
            );
        }

        return is_ignored;
    }

    log(LogLevel::Info, "No license specified, not ignoring");
    false
}

/// This is the default configuration
const EMBEDDED_LICENSE_COMPATIBILITY_TOML: &str =
    include_str!("../config/license_compatibility.toml");

/// Load license compatibility matrix from external TOML file if available
/// Looks for the file in the following order:
/// 1. .feluda/license_compatibility.toml (user-specific config directory)
/// 2. Embedded configuration
fn load_compatibility_matrix() -> FeludaResult<HashMap<String, Vec<String>>> {
    log(
        LogLevel::Info,
        "Loading license compatibility matrix from TOML file",
    );

    // Only check for user-specific config in .feluda directory
    let config_paths = vec![Path::new(".feluda/license_compatibility.toml").to_path_buf()];

    let mut config_content = None;
    let mut used_path = None;

    for path in &config_paths {
        if path.exists() {
            log(
                LogLevel::Info,
                &format!("Found license compatibility config at: {}", path.display()),
            );
            match fs::read_to_string(path) {
                Ok(content) => {
                    config_content = Some(content);
                    used_path = Some(path);
                    break;
                }
                Err(e) => {
                    log(
                        LogLevel::Warn,
                        &format!("Failed to read {}: {}", path.display(), e),
                    );
                    continue;
                }
            }
        }
    }

    // Use embedded configuration as fallback if no external file is found
    let config_content = match config_content {
        Some(content) => content,
        None => {
            log(
                LogLevel::Info,
                "No external license compatibility config found, using embedded configuration",
            );
            EMBEDDED_LICENSE_COMPATIBILITY_TOML.to_string()
        }
    };

    let matrix: LicenseCompatibilityMatrix = toml::from_str(&config_content).map_err(|e| {
        let source = match &used_path {
            Some(path) => format!("external config file ({})", path.display()),
            None => "embedded configuration".to_string(),
        };
        log(
            LogLevel::Error,
            &format!("Failed to parse license compatibility {source}: {e}"),
        );
        std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
    })?;

    // Convert TOML structure to HashMap
    let entries = [
        ("MIT", &matrix.mit),
        ("Apache-2.0", &matrix.apache_2_0),
        ("GPL-3.0", &matrix.gpl_3_0),
        ("GPL-2.0", &matrix.gpl_2_0),
        ("AGPL-3.0", &matrix.agpl_3_0),
        ("LGPL-3.0", &matrix.lgpl_3_0),
        ("LGPL-2.1", &matrix.lgpl_2_1),
        ("MPL-2.0", &matrix.mpl_2_0),
        ("BSD-3-Clause", &matrix.bsd_3_clause),
        ("BSD-2-Clause", &matrix.bsd_2_clause),
        ("ISC", &matrix.isc),
        ("0BSD", &matrix.bsd_0),
        ("Unlicense", &matrix.unlicense),
        ("WTFPL", &matrix.wtfpl),
    ];

    let result: HashMap<String, Vec<String>> = entries
        .iter()
        .filter_map(|(key, option_entry)| {
            option_entry
                .as_ref()
                .map(|entry| (key.to_string(), entry.compatible_with.clone()))
        })
        .collect();

    log(
        LogLevel::Info,
        &format!("Loaded {} license compatibility entries", result.len()),
    );
    Ok(result)
}

/// Get the compatibility matrix, loading it if not already cached
fn get_compatibility_matrix() -> &'static HashMap<String, Vec<String>> {
    #[cfg(not(test))]
    {
        COMPATIBILITY_MATRIX.get_or_init(|| {
            load_compatibility_matrix().unwrap_or_else(|e| {
                log(LogLevel::Error, &format!("Failed to load license compatibility matrix: {e}"));
                log(LogLevel::Error, "This is a critical error. The application cannot function without license compatibility data.");
                std::process::exit(1);
            })
        })
    }

    #[cfg(test)]
    {
        // For tests, use a thread-local storage to avoid the OnceLock static initialization issue
        use std::cell::RefCell;
        thread_local! {
            static MATRIX: RefCell<Option<HashMap<String, Vec<String>>>> = const { RefCell::new(None) };
        }

        // This is a hack to return a static reference from thread-local storage
        // We leak the memory in tests, which is acceptable for testing
        MATRIX.with(|m| {
            let mut matrix = m.borrow_mut();
            if matrix.is_none() {
                match load_compatibility_matrix() {
                    Ok(loaded_matrix) => {
                        *matrix = Some(loaded_matrix);
                    }
                    Err(e) => {
                        panic!(
                            "License compatibility configuration file not found during testing: {e}"
                        );
                    }
                }
            }

            // Leak the memory to get a static reference (only for tests)
            let leaked: &'static HashMap<String, Vec<String>> =
                Box::leak(Box::new(matrix.as_ref().unwrap().clone()));
            leaked
        })
    }
}

/// Check if a single (non-compound) dependency license ID is compatible with the project license.
fn is_single_license_compatible(
    dependency_license: &str,
    project_license: &str,
    strict: bool,
) -> LicenseCompatibility {
    let compatibility_matrix = get_compatibility_matrix();
    let norm_dep = normalize_license_id(dependency_license);
    let norm_proj = normalize_license_id(project_license);

    match compatibility_matrix.get(&norm_proj) {
        Some(compatible_licenses) => {
            if compatible_licenses.contains(&norm_dep) {
                LicenseCompatibility::Compatible
            } else {
                LicenseCompatibility::Incompatible
            }
        }
        None => {
            if strict {
                LicenseCompatibility::Incompatible
            } else {
                LicenseCompatibility::Unknown
            }
        }
    }
}

/// Check if a license is compatible with the base project license.
///
/// Handles compound SPDX expressions in `dependency_license`:
///   - `OR`  → compatible if ANY alternative is compatible with the project license.
///   - `AND` → compatible only if ALL components are compatible.
pub fn is_license_compatible(
    dependency_license: &str,
    project_license: &str,
    strict: bool,
) -> LicenseCompatibility {
    log(
        LogLevel::Info,
        &format!(
            "Checking compatibility: dependency={dependency_license} project={project_license} strict={strict}"
        ),
    );

    if spdx::is_compound(dependency_license) {
        let expr = spdx::parse(dependency_license);
        let result =
            spdx::expression_compatibility(&expr, project_license, strict, &|dep, proj, s| {
                is_single_license_compatible(dep, proj, s)
            });
        log(
            LogLevel::Info,
            &format!("Compound expression '{dependency_license}' compatibility={result}"),
        );
        return result;
    }

    let result = is_single_license_compatible(dependency_license, project_license, strict);
    log(
        LogLevel::Info,
        &format!("License {dependency_license} compatibility with {project_license}: {result}"),
    );
    result
}

/// Normalize license identifier to a standard format
fn normalize_license_id(license_id: &str) -> String {
    let trimmed = license_id.trim().to_uppercase();

    // Handle common variations and aliases
    match trimmed.as_str() {
        "MIT" | "MIT LICENSE" => "MIT".to_string(),
        "ISC" | "ISC LICENSE" => "ISC".to_string(),
        "0BSD" | "BSD-ZERO-CLAUSE" | "BSD ZERO CLAUSE" => "0BSD".to_string(),
        "UNLICENSE" | "THE UNLICENSE" => "Unlicense".to_string(),
        "WTFPL" | "DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE" => "WTFPL".to_string(),
        "ZLIB" | "ZLIB LICENSE" => "Zlib".to_string(),
        "CC0" | "CC0-1.0" | "CC0 1.0" | "CREATIVE COMMONS ZERO" => "CC0-1.0".to_string(),

        id if id.contains("APACHE") && (id.contains("2.0") || id.contains("2")) => {
            "Apache-2.0".to_string()
        }

        id if id.contains("AGPL") && id.contains("3") => "AGPL-3.0".to_string(),
        id if id.contains("AFFERO") && id.contains("GPL") && id.contains("3") => {
            "AGPL-3.0".to_string()
        }

        id if id.contains("GPL") && id.contains("3") && !id.contains("LGPL") => {
            "GPL-3.0".to_string()
        }
        id if id.contains("GPL") && id.contains("2") && !id.contains("LGPL") => {
            "GPL-2.0".to_string()
        }

        id if id.contains("LGPL") && id.contains("3") => "LGPL-3.0".to_string(),
        id if id.contains("LGPL") && id.contains("2.1") => "LGPL-2.1".to_string(),
        id if id.contains("LGPL") && id.contains("2") && !id.contains("2.1") => {
            "LGPL-2.1".to_string()
        }

        id if id.contains("MPL") && id.contains("2.0") => "MPL-2.0".to_string(),

        id if id.contains("BSD") && (id.contains("3") || id.contains("THREE")) => {
            "BSD-3-Clause".to_string()
        }
        id if id.contains("BSD") && (id.contains("2") || id.contains("TWO")) => {
            "BSD-2-Clause".to_string()
        }

        _ => license_id.to_string(),
    }
}

/// A well-known license filename.
///
/// `implied_spdx` is `Some` when the filename alone is sufficient to identify the license
/// (e.g. `OFL.txt` → `OFL-1.1`).  Generic filenames like `LICENSE` leave it `None` and fall
/// through to content-based detection.
struct LicenseFilename {
    filename: &'static str,
    implied_spdx: Option<&'static str>,
}

/// Content-based detection rule.
///
/// The rule fires when **any** marker group matches, where a group matches when
/// **all** of its strings appear in the file content (OR-of-ANDs).  List more-specific
/// rules (more markers) before less-specific ones so the first match wins.
struct LicenseContentRule {
    spdx_id: &'static str,
    marker_groups: &'static [&'static [&'static str]],
}

/// Canonical set of license filenames to probe, in priority order.
///
/// To add support for a new license filename:
///   • Append a `LicenseFilename` entry here.
///   • If the filename unambiguously identifies the license, set `implied_spdx`.
///   • Otherwise leave `implied_spdx: None`; content rules below will handle detection.
static LICENSE_FILENAMES: &[LicenseFilename] = &[
    LicenseFilename {
        filename: "LICENSE",
        implied_spdx: None,
    },
    LicenseFilename {
        filename: "LICENSE.txt",
        implied_spdx: None,
    },
    LicenseFilename {
        filename: "LICENSE.md",
        implied_spdx: None,
    },
    LicenseFilename {
        filename: "license",
        implied_spdx: None,
    },
    LicenseFilename {
        filename: "COPYING",
        implied_spdx: None,
    },
    LicenseFilename {
        filename: "COPYING.md",
        implied_spdx: None,
    },
    // `copyright` is the Debian/vcpkg convention (e.g. /usr/share/doc/<pkg>/copyright).
    LicenseFilename {
        filename: "copyright",
        implied_spdx: None,
    },
    // OFL.txt is the conventional license file for font projects (e.g. Google Fonts).
    LicenseFilename {
        filename: "OFL.txt",
        implied_spdx: Some("OFL-1.1"),
    },
    LicenseFilename {
        filename: "OFL-1.1.txt",
        implied_spdx: Some("OFL-1.1"),
    },
];

/// Content-matching rules applied when a filename has no implied SPDX ID.
///
/// To add a new license:
///   • Add a `LicenseContentRule` with the SPDX identifier and one or more marker groups.
///   • Each group is a set of strings that must ALL appear in the file (logical AND).
///   • Multiple groups are tried in order; the first matching group wins (logical OR).
///   • Place more-specific rules (more markers) before less-specific ones.
static LICENSE_CONTENT_RULES: &[LicenseContentRule] = &[
    // GPL family must be ordered specific → general: AGPL and LGPL texts both
    // contain the "GNU GENERAL PUBLIC LICENSE" substring (via reference to GPL),
    // so they would otherwise match the GPL rules first.
    LicenseContentRule {
        spdx_id: "AGPL-3.0",
        marker_groups: &[&["GNU AFFERO GENERAL PUBLIC LICENSE", "Version 3"]],
    },
    LicenseContentRule {
        spdx_id: "LGPL-3.0",
        marker_groups: &[&["GNU LESSER GENERAL PUBLIC LICENSE", "Version 3"]],
    },
    LicenseContentRule {
        spdx_id: "LGPL-2.1",
        marker_groups: &[&["GNU LESSER GENERAL PUBLIC LICENSE", "Version 2.1"]],
    },
    LicenseContentRule {
        spdx_id: "GPL-3.0",
        marker_groups: &[&["GNU GENERAL PUBLIC LICENSE", "Version 3"]],
    },
    LicenseContentRule {
        spdx_id: "GPL-2.0",
        marker_groups: &[&["GNU GENERAL PUBLIC LICENSE", "Version 2"]],
    },
    LicenseContentRule {
        spdx_id: "Apache-2.0",
        marker_groups: &[&["Apache License", "Version 2.0"]],
    },
    LicenseContentRule {
        spdx_id: "MPL-2.0",
        marker_groups: &[&["Mozilla Public License", "Version 2.0"]],
    },
    // BSD-3 must come before BSD-2: "Neither the name" distinguishes them.
    LicenseContentRule {
        spdx_id: "BSD-3-Clause",
        marker_groups: &[&["BSD", "Redistribution and use", "Neither the name"]],
    },
    LicenseContentRule {
        spdx_id: "BSD-2-Clause",
        marker_groups: &[&["BSD", "Redistribution and use"]],
    },
    // OFL must come before MIT: the OFL grant text begins with "Permission is hereby
    // granted, free of charge" (the same opening as MIT), so OFL files would otherwise
    // match the MIT rule first.
    LicenseContentRule {
        spdx_id: "OFL-1.1",
        marker_groups: &[
            &["SIL OPEN FONT LICENSE"],
            &["This Font Software is licensed under the SIL Open Font License"],
        ],
    },
    LicenseContentRule {
        spdx_id: "MIT",
        marker_groups: &[
            &["MIT License"],
            // "associated documentation files" is MIT-specific phrasing — it
            // disambiguates from OFL/ISC/etc. which share the permission preamble.
            &[
                "Permission is hereby granted, free of charge",
                "associated documentation files",
            ],
        ],
    },
    LicenseContentRule {
        spdx_id: "ISC",
        marker_groups: &[&["ISC License"]],
    },
    // Unlicense text is distinctive and shares no preamble with the licenses above,
    // so ordering relative to them does not matter.
    LicenseContentRule {
        spdx_id: "Unlicense",
        marker_groups: &[
            &["This is free and unencumbered software released into the public domain"],
            &["unlicense.org"],
        ],
    },
];

/// Return the SPDX ID for the first content rule that matches `content`, or `None`.
fn match_license_content(content: &str) -> Option<&'static str> {
    for rule in LICENSE_CONTENT_RULES {
        for group in rule.marker_groups {
            if group.iter().all(|marker| content.contains(marker)) {
                return Some(rule.spdx_id);
            }
        }
    }
    None
}

/// Detect a license's SPDX identifier from the **text content** of a license file
/// (`LICENSE`, `COPYING`, …) or any blob of license text.
///
/// Returns a canonical SPDX id (e.g. `"MIT"`, `"Apache-2.0"`) when the content matches a
/// known license, or `None` otherwise. This is the single shared implementation that every
/// language analyzer's local-license-file fallback routes through, so detection stays
/// consistent (and SPDX-correct) across ecosystems.
pub fn detect_license_from_content(content: &str) -> Option<String> {
    match_license_content(content).map(str::to_string)
}

/// The standardised SPDX source-header marker (SPDX spec, Annex E).
const SPDX_HEADER_MARKER: &str = "SPDX-License-Identifier:";

/// Maximum number of leading lines scanned for an `SPDX-License-Identifier:` tag.
/// Headers sit at the top of a file (optionally after a shebang or copyright banner), so
/// bounding the scan keeps large source files from being read in full and stops a tag buried
/// deep in the body from producing a false match.
const SOURCE_HEADER_SCAN_LINES: usize = 30;

/// Maximum directory depth descended when scanning a package's source for SPDX headers.
const SOURCE_HEADER_MAX_DEPTH: usize = 3;

/// Maximum number of source files inspected per directory before giving up. Caps the cost of
/// the fallback on large packages; when present, the header is conventionally on every file.
const SOURCE_HEADER_MAX_FILES: usize = 50;

/// File extensions whose leading comments are scanned for an SPDX header.
pub(crate) const SOURCE_HEADER_EXTENSIONS: &[&str] = &[
    "rs", "js", "jsx", "ts", "tsx", "mjs", "cjs", "py", "go", "rb", "java", "kt", "kts", "c", "h",
    "cc", "cpp", "cxx", "hpp", "hh", "cs", "php", "swift", "scala", "r",
];

/// Strip a trailing block-comment terminator (`*/`, `-->`) from a header line so the extracted
/// tag value isn't polluted by the comment syntax that closes it.
fn strip_comment_terminator(s: &str) -> &str {
    let s = s.trim_end();
    for terminator in ["*/", "-->"] {
        if let Some(stripped) = s.strip_suffix(terminator) {
            return stripped.trim_end();
        }
    }
    s
}

/// Extract an SPDX license expression from the leading comment region of a source file's
/// `content`.
///
/// Scans only the first [`SOURCE_HEADER_SCAN_LINES`] lines for the `SPDX-License-Identifier:`
/// marker (matched as a substring, so every comment style — `//`, `#`, `/* */`, `--`,
/// `<!-- -->` — works), strips a trailing block-comment terminator, and validates the value
/// through [`spdx::parse_strict`]. Returns the canonical expression string — so compounds like
/// `(MIT OR Apache-2.0)` flow through intact rather than as a literal — or `None` when no valid
/// tag is found, including when the marker appears only as prose (strict parsing rejects
/// anything that isn't a well-formed expression).
pub fn detect_license_from_source_header(content: &str) -> Option<String> {
    for line in content.lines().take(SOURCE_HEADER_SCAN_LINES) {
        let Some((_, after)) = line.split_once(SPDX_HEADER_MARKER) else {
            continue;
        };
        let expr = strip_comment_terminator(after).trim();
        if spdx::parse_strict(expr).is_some() {
            return Some(expr.to_string());
        }
    }
    None
}

/// Read at most [`SOURCE_HEADER_SCAN_LINES`] lines from `path` without loading the whole file,
/// so the header scan stays cheap on large sources. Returns `None` on any read error (e.g. a
/// non-UTF-8 file), which simply skips that file.
pub(crate) fn read_header_region(path: &Path) -> Option<String> {
    let file = match File::open(path) {
        Ok(file) => file,
        Err(err) => {
            log_debug(
                &format!(
                    "Failed to open source file for header scan: {}",
                    path.display()
                ),
                &err,
            );
            return None;
        }
    };

    let mut reader = BufReader::new(file);
    let mut header = String::new();
    let mut line = String::new();
    for _ in 0..SOURCE_HEADER_SCAN_LINES {
        line.clear();
        match reader.read_line(&mut line) {
            Ok(0) => break,
            Ok(_) => header.push_str(&line),
            Err(err) => {
                log_debug(
                    &format!(
                        "Failed to read source file for header scan: {}",
                        path.display()
                    ),
                    &err,
                );
                return None;
            }
        }
    }
    Some(header)
}

/// Scan a directory's source files for an `SPDX-License-Identifier:` header, returning the
/// first valid expression found, or `None`.
///
/// This is the source-header companion to the license-file probe in [`detect_license_in_dir`],
/// applied only as a last resort. The walk is bounded — it honours `.gitignore`, descends at
/// most [`SOURCE_HEADER_MAX_DEPTH`] levels, inspects at most [`SOURCE_HEADER_MAX_FILES`] files,
/// considers only recognised source extensions, and reads only each file's leading region — so
/// it stays cheap and is unlikely to pick up a vendored file's foreign header. Entries are
/// visited in a stable order so the result is deterministic.
fn detect_spdx_header_in_dir(dir: &Path) -> Option<String> {
    let walker = WalkBuilder::new(dir)
        .max_depth(Some(SOURCE_HEADER_MAX_DEPTH))
        .sort_by_file_path(|a, b| a.cmp(b))
        .build();

    let mut files_scanned = 0;
    for entry in walker.flatten() {
        if files_scanned >= SOURCE_HEADER_MAX_FILES {
            break;
        }
        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
            continue;
        }

        let path = entry.path();
        let is_source = path
            .extension()
            .and_then(|ext| ext.to_str())
            .is_some_and(|ext| {
                SOURCE_HEADER_EXTENSIONS
                    .iter()
                    .any(|known| known.eq_ignore_ascii_case(ext))
            });
        if !is_source {
            continue;
        }

        files_scanned += 1;
        let Some(header) = read_header_region(path) else {
            continue;
        };
        if let Some(spdx) = detect_license_from_source_header(&header) {
            log(
                LogLevel::Info,
                &format!(
                    "Detected {spdx} license from SPDX header in {}",
                    path.display()
                ),
            );
            return Some(spdx);
        }
    }
    None
}

/// Probe a directory for a conventional license file (`LICENSE`, `COPYING`, …) and return
/// its canonical SPDX id, or `None` if no recognizable license is found.
///
/// This is the directory-level companion to [`detect_license_from_content`]: every language
/// analyzer's local-license-file fallback routes through it, so the filename list and the
/// filename → SPDX shortcuts (e.g. `OFL.txt` → `OFL-1.1`) stay defined in exactly one place.
///
/// Detection runs in two stages, strictly in priority order: first the conventional license
/// files, then — only if none resolves — a bounded scan of the directory's source files for an
/// `SPDX-License-Identifier:` header (see [`detect_spdx_header_in_dir`]). The header scan is a
/// last resort, so a real license file always wins.
pub fn detect_license_in_dir(dir: &Path) -> Option<String> {
    for entry in LICENSE_FILENAMES {
        let license_path = dir.join(entry.filename);
        if !license_path.exists() {
            continue;
        }

        // Filename alone is sufficient (e.g. OFL.txt → OFL-1.1).
        if let Some(spdx) = entry.implied_spdx {
            return Some(spdx.to_string());
        }

        match fs::read_to_string(&license_path) {
            Ok(content) => {
                if let Some(spdx) = match_license_content(&content) {
                    return Some(spdx.to_string());
                }
            }
            Err(err) => {
                log_debug(
                    &format!("Failed to read license file: {}", license_path.display()),
                    &err,
                );
            }
        }
    }

    // Fallback: no conventional license file resolved — scan source headers.
    detect_spdx_header_in_dir(dir)
}

/// Read the raw text of the first license file found in `dir`, or `None` if the directory has no
/// readable license file.
///
/// This is the text-returning companion to [`detect_license_in_dir`]: that function resolves a
/// canonical SPDX id, whereas this one returns the verbatim license text for inclusion in a
/// generated `THIRD_PARTY_LICENSES` document. It first tries the canonical [`LICENSE_FILENAMES`]
/// in priority order, then falls back to scanning for variant filenames — dual-licensed crates
/// routinely ship `LICENSE-MIT`/`LICENSE-APACHE`, and others use `LICENCE`, `LICENSE.rst`,
/// `COPYING.LESSER`, etc. Empty/whitespace-only files are skipped so a stray placeholder doesn't
/// shadow a real license.
pub fn read_license_text_in_dir(dir: &Path) -> Option<String> {
    for entry in LICENSE_FILENAMES {
        let license_path = dir.join(entry.filename);
        match fs::read_to_string(&license_path) {
            Ok(content) if !content.trim().is_empty() => return Some(content),
            _ => continue,
        }
    }

    // Fallback: scan the directory for variant license filenames the canonical list misses.
    let mut candidates: Vec<PathBuf> = fs::read_dir(dir)
        .ok()?
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| path.is_file() && looks_like_license_file(path))
        .collect();
    // Deterministic order so a fixed choice is made among e.g. LICENSE-APACHE / LICENSE-MIT.
    candidates.sort();

    for path in candidates {
        match fs::read_to_string(&path) {
            Ok(content) if !content.trim().is_empty() => return Some(content),
            _ => continue,
        }
    }

    None
}

/// Whether a path's filename looks like a license file (`LICENSE*`, `LICENCE*`, `COPYING*`),
/// excluding source files so we never mistake code (e.g. `license.go`) for license text.
fn looks_like_license_file(path: &Path) -> bool {
    const CODE_EXTENSIONS: &[&str] = &[
        "rs", "go", "py", "js", "ts", "c", "h", "cpp", "cc", "hpp", "java", "rb", "cs", "php",
        "sh", "toml", "json", "yaml", "yml", "lock",
    ];

    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
        if CODE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) {
            return false;
        }
    }

    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| {
            let upper = name.to_ascii_uppercase();
            upper.starts_with("LICENSE")
                || upper.starts_with("LICENCE")
                || upper.starts_with("COPYING")
        })
        .unwrap_or(false)
}

/// Detect the project's license
pub fn detect_project_license(project_path: &str) -> FeludaResult<Option<String>> {
    log(
        LogLevel::Info,
        &format!("Detecting license for project at path: {project_path}"),
    );

    if let Some(spdx) = detect_license_in_dir(Path::new(project_path)) {
        log(
            LogLevel::Info,
            &format!("Detected {spdx} license from project license file"),
        );
        return Ok(Some(spdx));
    }

    // Check package.json for Node.js projects
    let package_json_path = Path::new(project_path).join("package.json");
    if package_json_path.exists() {
        log(
            LogLevel::Info,
            &format!("Found package.json at {}", package_json_path.display()),
        );

        match fs::read_to_string(&package_json_path) {
            Ok(content) => match serde_json::from_str::<Value>(&content) {
                Ok(json) => {
                    if let Some(license) = json.get("license").and_then(|l| l.as_str()) {
                        log(
                            LogLevel::Info,
                            &format!("Detected license from package.json: {license}"),
                        );
                        return Ok(Some(license.to_string()));
                    }
                }
                Err(err) => {
                    log(
                        LogLevel::Error,
                        &format!("Failed to parse package.json: {err}"),
                    );
                }
            },
            Err(err) => {
                log(
                    LogLevel::Error,
                    &format!(
                        "Failed to read package.json: {}",
                        package_json_path.display()
                    ),
                );
                log_debug("Error details", &err);
            }
        }
    }

    // Check Cargo.toml for Rust projects
    let cargo_toml_path = Path::new(project_path).join("Cargo.toml");
    if cargo_toml_path.exists() {
        log(
            LogLevel::Info,
            &format!("Found Cargo.toml at {}", cargo_toml_path.display()),
        );

        match fs::read_to_string(&cargo_toml_path) {
            Ok(content) => match toml::from_str::<TomlValue>(&content) {
                Ok(toml) => {
                    if let Some(package) = toml.as_table().and_then(|t| t.get("package")) {
                        if let Some(license) = package.get("license").and_then(|l| l.as_str()) {
                            log(
                                LogLevel::Info,
                                &format!("Detected license from Cargo.toml: {license}"),
                            );
                            return Ok(Some(license.to_string()));
                        }
                    }
                }
                Err(err) => {
                    log(
                        LogLevel::Error,
                        &format!("Failed to parse Cargo.toml: {err}"),
                    );
                }
            },
            Err(err) => {
                log(
                    LogLevel::Error,
                    &format!("Failed to read Cargo.toml: {}", cargo_toml_path.display()),
                );
                log_debug("Error details", &err);
            }
        }
    }

    // Check pyproject.toml for Python projects
    let pyproject_toml_path = Path::new(project_path).join("pyproject.toml");
    if pyproject_toml_path.exists() {
        log(
            LogLevel::Info,
            &format!("Found pyproject.toml at {}", pyproject_toml_path.display()),
        );

        match fs::read_to_string(&pyproject_toml_path) {
            Ok(content) => match toml::from_str::<TomlValue>(&content) {
                Ok(toml) => {
                    if let Some(project) = toml.as_table().and_then(|t| t.get("project")) {
                        if let Some(license_info) = project.get("license") {
                            if let Some(license) = license_info.as_str() {
                                log(
                                    LogLevel::Info,
                                    &format!("Detected license from pyproject.toml: {license}"),
                                );
                                return Ok(Some(license.to_string()));
                            } else if let Some(license_table) = license_info.as_table() {
                                if let Some(license_text) =
                                    license_table.get("text").and_then(|t| t.as_str())
                                {
                                    log(
                                        LogLevel::Info,
                                        &format!(
                                            "Detected license from pyproject.toml: {license_text}"
                                        ),
                                    );
                                    return Ok(Some(license_text.to_string()));
                                }
                            }
                        }
                    }
                }
                Err(err) => {
                    log(
                        LogLevel::Error,
                        &format!("Failed to parse pyproject.toml: {err}"),
                    );
                }
            },
            Err(err) => {
                log(
                    LogLevel::Error,
                    &format!(
                        "Failed to read pyproject.toml: {}",
                        pyproject_toml_path.display()
                    ),
                );
                log_debug("Error details", &err);
            }
        }
    }

    log(LogLevel::Warn, "No license detected for project");
    Ok(None)
}

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

    #[test]
    fn test_license_compatibility_display() {
        assert_eq!(LicenseCompatibility::Compatible.to_string(), "Compatible");
        assert_eq!(
            LicenseCompatibility::Incompatible.to_string(),
            "Incompatible"
        );
        assert_eq!(LicenseCompatibility::Unknown.to_string(), "Unknown");
    }

    #[test]
    fn test_license_info_methods() {
        let info = LicenseInfo {
            name: "test_package".to_string(),
            version: "1.0.0".to_string(),
            license: Some("MIT".to_string()),
            is_restrictive: false,
            compatibility: LicenseCompatibility::Compatible,
            osi_status: OsiStatus::Approved,
            sub_project: None,
        };

        assert_eq!(info.name(), "test_package");
        assert_eq!(info.version(), "1.0.0");
        assert_eq!(info.get_license(), "MIT");
        assert!(!info.is_restrictive());
        assert_eq!(info.compatibility(), &LicenseCompatibility::Compatible);
    }

    #[test]
    fn test_license_info_no_license() {
        let info = LicenseInfo {
            name: "test_package".to_string(),
            version: "1.0.0".to_string(),
            license: None,
            is_restrictive: true,
            compatibility: LicenseCompatibility::Unknown,
            osi_status: OsiStatus::Unknown,
            sub_project: None,
        };

        assert_eq!(info.get_license(), "No License");
    }

    #[test]
    fn test_normalize_license_id() {
        assert_eq!(normalize_license_id("MIT"), "MIT");
        assert_eq!(normalize_license_id("mit"), "MIT");
        assert_eq!(normalize_license_id("Apache 2.0"), "Apache-2.0");
        assert_eq!(normalize_license_id("APACHE-2.0"), "Apache-2.0");
        assert_eq!(normalize_license_id("GPL 3.0"), "GPL-3.0");
        assert_eq!(normalize_license_id("gpl-3.0"), "GPL-3.0");
        assert_eq!(normalize_license_id("LGPL 3.0"), "LGPL-3.0");
        assert_eq!(normalize_license_id("MPL 2.0"), "MPL-2.0");
        assert_eq!(normalize_license_id("BSD 3-Clause"), "BSD-3-Clause");
        assert_eq!(normalize_license_id("BSD 2-Clause"), "BSD-2-Clause");
        assert_eq!(normalize_license_id("Unknown License"), "Unknown License");
        assert_eq!(normalize_license_id("  MIT  "), "MIT");
    }

    #[test]
    #[ignore] // Skip this test due to static initialization issues in test runner
    fn test_is_license_compatible_mit_project() {
        assert_eq!(
            is_license_compatible("MIT", "MIT", false),
            LicenseCompatibility::Compatible
        );
        assert_eq!(
            is_license_compatible("BSD-2-Clause", "MIT", false),
            LicenseCompatibility::Compatible
        );
        assert_eq!(
            is_license_compatible("BSD-3-Clause", "MIT", false),
            LicenseCompatibility::Compatible
        );
        assert_eq!(
            is_license_compatible("Apache-2.0", "MIT", false),
            LicenseCompatibility::Compatible
        );
        assert_eq!(
            is_license_compatible("LGPL-3.0", "MIT", false),
            LicenseCompatibility::Incompatible
        );
        assert_eq!(
            is_license_compatible("MPL-2.0", "MIT", false),
            LicenseCompatibility::Incompatible
        );
        assert_eq!(
            is_license_compatible("GPL-3.0", "MIT", false),
            LicenseCompatibility::Incompatible
        );
    }

    #[test]
    fn test_detect_project_license_mit_file() {
        let temp_dir = TempDir::new().unwrap();
        let license_path = temp_dir.path().join("LICENSE");

        std::fs::write(
            &license_path,
            "MIT License\n\nPermission is hereby granted, free of charge...",
        )
        .unwrap();

        let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap();
        assert_eq!(result, Some("MIT".to_string()));
    }

    #[test]
    fn test_detect_project_license_ofl_filename() {
        // OFL.txt is the canonical font license file; filename alone should suffice.
        let temp_dir = TempDir::new().unwrap();
        std::fs::write(temp_dir.path().join("OFL.txt"), "some font license text").unwrap();
        let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap();
        assert_eq!(result, Some("OFL-1.1".to_string()));
    }

    #[test]
    fn test_detect_project_license_ofl_content_in_license_file() {
        // When only a generic LICENSE file is present, content-based detection must fire.
        let temp_dir = TempDir::new().unwrap();
        std::fs::write(
            temp_dir.path().join("LICENSE"),
            "SIL OPEN FONT LICENSE\nVersion 1.1",
        )
        .unwrap();
        let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap();
        assert_eq!(result, Some("OFL-1.1".to_string()));
    }

    #[test]
    fn test_detect_project_license_lgpl_not_misidentified_as_gpl() {
        // Regression: LGPL text contains "GNU GENERAL PUBLIC LICENSE" via reference,
        // so without correct rule ordering it would be misidentified as GPL-3.0.
        let temp_dir = TempDir::new().unwrap();
        std::fs::write(
            temp_dir.path().join("LICENSE"),
            "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3\n\
             This version of the GNU Lesser General Public License incorporates \
             the terms and conditions of version 3 of the GNU General Public License.",
        )
        .unwrap();
        let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap();
        assert_eq!(result, Some("LGPL-3.0".to_string()));
    }

    #[test]
    fn test_detect_project_license_ofl_in_license_not_misidentified_as_mit() {
        // Regression: OFL grant text starts with the same "Permission is hereby granted,
        // free of charge" preamble as MIT, so without correct rule ordering it would be
        // misidentified as MIT.
        let temp_dir = TempDir::new().unwrap();
        std::fs::write(
            temp_dir.path().join("LICENSE"),
            "SIL OPEN FONT LICENSE Version 1.1\n\n\
             Permission is hereby granted, free of charge, to any person obtaining \
             a copy of the Font Software, to use, study, copy, merge, embed, modify...",
        )
        .unwrap();
        let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap();
        assert_eq!(result, Some("OFL-1.1".to_string()));
    }

    #[test]
    fn test_detect_project_license_no_license() {
        let temp_dir = TempDir::new().unwrap();

        let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap();
        assert_eq!(result, None);
    }

    #[test]
    fn test_is_license_ignored_with_no_license() {
        // Should return false when no license is provided
        assert!(!is_license_ignored(None));
    }

    #[test]
    fn test_is_license_ignored_not_in_ignore_list() {
        // License not in ignore list should return false
        // This test assumes no ignore list is configured
        let result = is_license_ignored(Some("GPL-3.0"));
        // Since we can't easily mock the config in this context,
        // we just verify it doesn't panic
        let _ = result;
    }

    #[test]
    fn test_is_license_ignored_empty_license() {
        // Empty string should return false
        assert!(!is_license_ignored(Some("")));
    }

    #[test]
    fn test_detect_license_from_content_returns_spdx_ids() {
        // The shared detector returns canonical SPDX ids, not free-form labels.
        assert_eq!(
            detect_license_from_content("MIT License\n\nCopyright (c) 2024"),
            Some("MIT".to_string())
        );
        assert_eq!(
            detect_license_from_content("Apache License\nVersion 2.0"),
            Some("Apache-2.0".to_string())
        );
        assert_eq!(
            detect_license_from_content("GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991"),
            Some("GPL-2.0".to_string())
        );
    }

    #[test]
    fn test_detect_license_from_content_lgpl_not_gpl() {
        // Regression guard: the old per-language detectors matched the bare "GPL"
        // substring before "LGPL", so an LGPL file was reported as GPL.
        let lgpl = "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007";
        assert_eq!(
            detect_license_from_content(lgpl),
            Some("LGPL-3.0".to_string())
        );
    }

    #[test]
    fn test_detect_license_from_content_unlicense() {
        let unlicense = "This is free and unencumbered software released into the public domain.";
        assert_eq!(
            detect_license_from_content(unlicense),
            Some("Unlicense".to_string())
        );
    }

    #[test]
    fn test_detect_license_from_content_no_match() {
        assert_eq!(detect_license_from_content("Some random content"), None);
    }

    #[test]
    fn test_detect_license_in_dir_content() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("LICENSE"),
            "Apache License\nVersion 2.0, January 2004",
        )
        .unwrap();
        assert_eq!(
            detect_license_in_dir(dir.path()),
            Some("Apache-2.0".to_string())
        );
    }

    #[test]
    fn test_detect_license_in_dir_implied_from_filename() {
        // OFL.txt resolves from the filename alone, even with empty content.
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("OFL.txt"), "").unwrap();
        assert_eq!(
            detect_license_in_dir(dir.path()),
            Some("OFL-1.1".to_string())
        );
    }

    #[test]
    fn test_detect_license_in_dir_copying() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("COPYING"),
            "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
        )
        .unwrap();
        assert_eq!(
            detect_license_in_dir(dir.path()),
            Some("GPL-3.0".to_string())
        );
    }

    #[test]
    fn test_detect_license_in_dir_none() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(detect_license_in_dir(dir.path()), None);
    }

    #[test]
    fn test_read_license_text_in_dir_returns_raw_text() {
        let dir = tempfile::tempdir().unwrap();
        let body = "MIT License\n\nCopyright (c) 2026 Someone\n\nPermission is hereby granted";
        fs::write(dir.path().join("LICENSE"), body).unwrap();
        assert_eq!(read_license_text_in_dir(dir.path()).as_deref(), Some(body));
    }

    #[test]
    fn test_read_license_text_in_dir_none_when_absent() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(read_license_text_in_dir(dir.path()), None);
    }

    #[test]
    fn test_read_license_text_in_dir_skips_empty_file() {
        let dir = tempfile::tempdir().unwrap();
        // An empty LICENSE must not shadow a real COPYING further down the priority list.
        fs::write(dir.path().join("LICENSE"), "   \n\t\n").unwrap();
        fs::write(dir.path().join("COPYING"), "GNU GENERAL PUBLIC LICENSE").unwrap();
        assert_eq!(
            read_license_text_in_dir(dir.path()).as_deref(),
            Some("GNU GENERAL PUBLIC LICENSE")
        );
    }

    #[test]
    fn test_read_license_text_in_dir_variant_filenames() {
        // Dual-licensed crates (e.g. serde) ship LICENSE-APACHE / LICENSE-MIT, which the
        // canonical filename list doesn't contain; the fallback scan must find them.
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("LICENSE-APACHE"),
            "Apache License\nVersion 2.0",
        )
        .unwrap();
        fs::write(dir.path().join("LICENSE-MIT"), "MIT License").unwrap();
        // Sorted order picks LICENSE-APACHE deterministically.
        assert_eq!(
            read_license_text_in_dir(dir.path()).as_deref(),
            Some("Apache License\nVersion 2.0")
        );
    }

    #[test]
    fn test_read_license_text_in_dir_ignores_source_files() {
        // A source file named license.go must never be returned as license text.
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("license.go"),
            "package main\n// not a license",
        )
        .unwrap();
        assert_eq!(read_license_text_in_dir(dir.path()), None);
    }

    fn license_with_conditions(spdx: &str, conditions: &[&str]) -> License {
        License {
            title: spdx.to_string(),
            spdx_id: spdx.to_string(),
            permissions: Vec::new(),
            conditions: conditions.iter().map(|c| c.to_string()).collect(),
            limitations: Vec::new(),
        }
    }

    fn registry_with(entries: &[(&str, &[&str])]) -> HashMap<String, License> {
        entries
            .iter()
            .map(|(spdx, conds)| (spdx.to_string(), license_with_conditions(spdx, conds)))
            .collect()
    }

    #[test]
    fn test_registry_gpl_is_restrictive_in_default_mode() {
        // Regression for #31: a GPL-family license present in the registry was classified as
        // non-restrictive in the default (non-strict) mode because the code matched a
        // non-existent condition key (`source-disclosure`). GitHub reports GPL-3.0 with the
        // real key `disclose-source`.
        let registry = registry_with(&[(
            "GPL-3.0",
            &[
                "include-copyright",
                "document-changes",
                "disclose-source",
                "same-license",
            ],
        )]);
        assert!(is_license_restrictive(
            &Some("GPL-3.0".to_string()),
            &registry,
            false
        ));
        assert!(is_license_restrictive(
            &Some("GPL-3.0".to_string()),
            &registry,
            true
        ));
    }

    #[test]
    fn test_registry_agpl_is_restrictive_via_network_disclosure() {
        let registry = registry_with(&[(
            "AGPL-3.0",
            &[
                "include-copyright",
                "disclose-source",
                "network-use-disclosure",
                "same-license",
            ],
        )]);
        assert!(is_license_restrictive(
            &Some("AGPL-3.0".to_string()),
            &registry,
            false
        ));
    }

    #[test]
    fn test_registry_matches_only_or_later_suffixed_ids() {
        // Registry keys are bare ids (`GPL-2.0`); SPDX `-only`/`-or-later`/`+` modifiers must
        // classify like their base license instead of falling through to the config list
        // (which does not cover every GPL-family version).
        let registry = registry_with(&[("GPL-2.0", &["include-copyright", "disclose-source"])]);
        for id in ["GPL-2.0-or-later", "GPL-2.0-only", "GPL-2.0+"] {
            assert!(
                is_license_restrictive(&Some(id.to_string()), &registry, false),
                "{id} should classify like GPL-2.0"
            );
        }
    }

    #[test]
    fn test_registry_permissive_not_restrictive() {
        let registry = registry_with(&[
            ("MIT", &["include-copyright"]),
            ("Apache-2.0", &["include-copyright", "document-changes"]),
        ]);
        assert!(!is_license_restrictive(
            &Some("MIT".to_string()),
            &registry,
            false
        ));
        assert!(!is_license_restrictive(
            &Some("Apache-2.0".to_string()),
            &registry,
            false
        ));
    }

    #[test]
    fn test_registry_share_alike_only_restrictive_in_strict_mode() {
        // A share-alike license carrying no source-disclosure obligation is treated as
        // restrictive only under strict mode.
        let registry = registry_with(&[("CC-BY-SA-4.0", &["include-copyright", "same-license"])]);
        assert!(!is_license_restrictive(
            &Some("CC-BY-SA-4.0".to_string()),
            &registry,
            false
        ));
        assert!(is_license_restrictive(
            &Some("CC-BY-SA-4.0".to_string()),
            &registry,
            true
        ));
    }

    #[test]
    fn test_source_header_single_identifier() {
        assert_eq!(
            detect_license_from_source_header("// SPDX-License-Identifier: MIT\nfn main() {}"),
            Some("MIT".to_string())
        );
    }

    #[test]
    fn test_source_header_comment_styles() {
        // The marker is matched as a substring, so every comment style resolves.
        for header in [
            "# SPDX-License-Identifier: Apache-2.0",
            "/* SPDX-License-Identifier: Apache-2.0 */",
            " * SPDX-License-Identifier: Apache-2.0",
            "-- SPDX-License-Identifier: Apache-2.0",
            "<!-- SPDX-License-Identifier: Apache-2.0 -->",
        ] {
            assert_eq!(
                detect_license_from_source_header(header),
                Some("Apache-2.0".to_string()),
                "failed for header: {header}"
            );
        }
    }

    #[test]
    fn test_source_header_compound_expression() {
        // A compound tag flows through as the expression string, not a literal label.
        assert_eq!(
            detect_license_from_source_header("// SPDX-License-Identifier: (MIT OR Apache-2.0)"),
            Some("(MIT OR Apache-2.0)".to_string())
        );
        assert_eq!(
            detect_license_from_source_header(
                "/* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 */"
            ),
            Some("GPL-2.0-only WITH Classpath-exception-2.0".to_string())
        );
    }

    #[test]
    fn test_source_header_missing_tag() {
        assert_eq!(
            detect_license_from_source_header("fn main() {\n    println!(\"hello\");\n}"),
            None
        );
    }

    #[test]
    fn test_source_header_tag_outside_leading_region() {
        // A tag buried past the leading scan window must not match.
        let mut content = String::new();
        for i in 0..SOURCE_HEADER_SCAN_LINES + 5 {
            content.push_str(&format!("// filler line {i}\n"));
        }
        content.push_str("// SPDX-License-Identifier: MIT\n");
        assert_eq!(detect_license_from_source_header(&content), None);
    }

    #[test]
    fn test_source_header_rejects_prose_mention() {
        // The marker appearing in prose (no well-formed expression after it) is rejected
        // by strict SPDX parsing, so it doesn't produce a false match.
        assert_eq!(
            detect_license_from_source_header(
                "// see the SPDX-License-Identifier: header value for details"
            ),
            None
        );
    }

    #[test]
    fn test_detect_license_in_dir_source_header_fallback() {
        // No LICENSE file, but a source file carries an SPDX header.
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("lib.rs"),
            "// SPDX-License-Identifier: BSD-3-Clause\npub fn f() {}",
        )
        .unwrap();
        assert_eq!(
            detect_license_in_dir(dir.path()),
            Some("BSD-3-Clause".to_string())
        );
    }

    #[test]
    fn test_detect_license_in_dir_license_file_beats_header() {
        // A real license file always wins over a source header.
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("LICENSE"), "MIT License\n\nCopyright (c)").unwrap();
        fs::write(
            dir.path().join("lib.rs"),
            "// SPDX-License-Identifier: GPL-3.0-only\npub fn f() {}",
        )
        .unwrap();
        assert_eq!(detect_license_in_dir(dir.path()), Some("MIT".to_string()));
    }

    #[test]
    fn test_detect_license_in_dir_ignores_non_source_files() {
        // A header-like marker in a non-source file is not scanned.
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("notes.txt"), "SPDX-License-Identifier: MIT").unwrap();
        assert_eq!(detect_license_in_dir(dir.path()), None);
    }
}
