//! Inline suppression comment types and issue kind definitions.

/// Issue kind for suppression matching.
///
/// # Examples
///
/// ```
/// use fallow_types::suppress::IssueKind;
///
/// let kind = IssueKind::parse("unused-export");
/// assert_eq!(kind, Some(IssueKind::UnusedExport));
///
/// // Round-trip through discriminant
/// let d = IssueKind::UnusedFile.to_discriminant();
/// assert_eq!(IssueKind::from_discriminant(d), Some(IssueKind::UnusedFile));
///
/// // Unknown strings return None
/// assert_eq!(IssueKind::parse("not-a-kind"), None);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueKind {
    /// An unused file.
    UnusedFile,
    /// An unused export.
    UnusedExport,
    /// An unused type export.
    UnusedType,
    /// An exported signature that references a same-file private type.
    PrivateTypeLeak,
    /// An unused dependency.
    UnusedDependency,
    /// An unused dev dependency.
    UnusedDevDependency,
    /// An unused enum member.
    UnusedEnumMember,
    /// An unused class member.
    UnusedClassMember,
    /// An unresolved import.
    UnresolvedImport,
    /// An unlisted dependency.
    UnlistedDependency,
    /// A duplicate export name across modules.
    DuplicateExport,
    /// Code duplication.
    CodeDuplication,
    /// A circular dependency chain.
    CircularDependency,
    /// A cycle or self-loop in the re-export edge subgraph (barrel files
    /// re-exporting from each other in a loop). Structurally always a bug:
    /// chain propagation through the cycle is a no-op.
    ReExportCycle,
    /// A production dependency only imported via type-only imports.
    TypeOnlyDependency,
    /// A production dependency only imported by test files.
    TestOnlyDependency,
    /// An import that crosses an architecture boundary.
    BoundaryViolation,
    /// A runtime file or export with no test dependency path.
    CoverageGaps,
    /// A detected feature flag pattern.
    FeatureFlag,
    /// A function exceeding complexity thresholds (health command).
    Complexity,
    /// A suppression comment or JSDoc tag that no longer matches any issue.
    StaleSuppression,
    /// A pnpm catalog entry in pnpm-workspace.yaml not referenced by any workspace package.
    PnpmCatalogEntry,
    /// A named pnpm catalog group in pnpm-workspace.yaml with no entries.
    EmptyCatalogGroup,
    /// A workspace package.json reference (`catalog:` / `catalog:<name>`) pointing at
    /// a catalog that does not declare the consumed package.
    UnresolvedCatalogReference,
    /// An entry in pnpm's `overrides:` / `pnpm.overrides` whose target package
    /// is not declared in any workspace `package.json`.
    UnusedDependencyOverride,
    /// An entry in pnpm's `overrides:` / `pnpm.overrides` whose key or value
    /// cannot be parsed into a valid pnpm shape.
    MisconfiguredDependencyOverride,
    /// A `"use client"` file that transitively imports a module reading a
    /// non-public `process.env` secret (security candidate).
    SecurityClientServerLeak,
    /// A syntactic tainted-sink candidate matched against the data-driven
    /// security matcher catalogue (`security_matchers.toml`). ONE suppression
    /// token covers all catalogue categories.
    SecuritySink,
}

impl IssueKind {
    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "unused-file" => Some(Self::UnusedFile),
            "unused-export" => Some(Self::UnusedExport),
            "unused-type" => Some(Self::UnusedType),
            "private-type-leak" => Some(Self::PrivateTypeLeak),
            "unused-dependency" => Some(Self::UnusedDependency),
            "unused-dev-dependency" => Some(Self::UnusedDevDependency),
            "unused-enum-member" => Some(Self::UnusedEnumMember),
            "unused-class-member" => Some(Self::UnusedClassMember),
            "unresolved-import" => Some(Self::UnresolvedImport),
            "unlisted-dependency" => Some(Self::UnlistedDependency),
            "duplicate-export" => Some(Self::DuplicateExport),
            "code-duplication" => Some(Self::CodeDuplication),
            "circular-dependency" | "circular-dependencies" => Some(Self::CircularDependency),
            "re-export-cycle" | "re-export-cycles" | "reexport-cycle" | "reexport-cycles" => {
                Some(Self::ReExportCycle)
            }
            "type-only-dependency" => Some(Self::TypeOnlyDependency),
            "test-only-dependency" => Some(Self::TestOnlyDependency),
            "boundary-violation" => Some(Self::BoundaryViolation),
            "coverage-gaps" => Some(Self::CoverageGaps),
            "feature-flag" => Some(Self::FeatureFlag),
            "complexity" => Some(Self::Complexity),
            "stale-suppression" => Some(Self::StaleSuppression),
            "unused-catalog-entry" | "unused-catalog-entries" => Some(Self::PnpmCatalogEntry),
            "empty-catalog-group" | "empty-catalog-groups" => Some(Self::EmptyCatalogGroup),
            "unresolved-catalog-reference" | "unresolved-catalog-references" => {
                Some(Self::UnresolvedCatalogReference)
            }
            "unused-dependency-override" | "unused-dependency-overrides" => {
                Some(Self::UnusedDependencyOverride)
            }
            "misconfigured-dependency-override" | "misconfigured-dependency-overrides" => {
                Some(Self::MisconfiguredDependencyOverride)
            }
            "security-client-server-leak" => Some(Self::SecurityClientServerLeak),
            "security-sink" => Some(Self::SecuritySink),
            _ => None,
        }
    }

    /// Convert to a u8 discriminant for compact cache storage.
    #[must_use]
    pub const fn to_discriminant(self) -> u8 {
        match self {
            Self::UnusedFile => 1,
            Self::UnusedExport => 2,
            Self::UnusedType => 3,
            Self::PrivateTypeLeak => 4,
            Self::UnusedDependency => 5,
            Self::UnusedDevDependency => 6,
            Self::UnusedEnumMember => 7,
            Self::UnusedClassMember => 8,
            Self::UnresolvedImport => 9,
            Self::UnlistedDependency => 10,
            Self::DuplicateExport => 11,
            Self::CodeDuplication => 12,
            Self::CircularDependency => 13,
            Self::TypeOnlyDependency => 14,
            Self::TestOnlyDependency => 15,
            Self::BoundaryViolation => 16,
            Self::CoverageGaps => 17,
            Self::FeatureFlag => 18,
            Self::Complexity => 19,
            Self::StaleSuppression => 20,
            Self::PnpmCatalogEntry => 21,
            Self::UnresolvedCatalogReference => 22,
            Self::UnusedDependencyOverride => 23,
            Self::MisconfiguredDependencyOverride => 24,
            Self::EmptyCatalogGroup => 25,
            Self::ReExportCycle => 26,
            Self::SecurityClientServerLeak => 27,
            Self::SecuritySink => 28,
        }
    }

    /// Reconstruct from a cache discriminant.
    #[must_use]
    pub const fn from_discriminant(d: u8) -> Option<Self> {
        match d {
            1 => Some(Self::UnusedFile),
            2 => Some(Self::UnusedExport),
            3 => Some(Self::UnusedType),
            4 => Some(Self::PrivateTypeLeak),
            5 => Some(Self::UnusedDependency),
            6 => Some(Self::UnusedDevDependency),
            7 => Some(Self::UnusedEnumMember),
            8 => Some(Self::UnusedClassMember),
            9 => Some(Self::UnresolvedImport),
            10 => Some(Self::UnlistedDependency),
            11 => Some(Self::DuplicateExport),
            12 => Some(Self::CodeDuplication),
            13 => Some(Self::CircularDependency),
            14 => Some(Self::TypeOnlyDependency),
            15 => Some(Self::TestOnlyDependency),
            16 => Some(Self::BoundaryViolation),
            17 => Some(Self::CoverageGaps),
            18 => Some(Self::FeatureFlag),
            19 => Some(Self::Complexity),
            20 => Some(Self::StaleSuppression),
            21 => Some(Self::PnpmCatalogEntry),
            22 => Some(Self::UnresolvedCatalogReference),
            23 => Some(Self::UnusedDependencyOverride),
            24 => Some(Self::MisconfiguredDependencyOverride),
            25 => Some(Self::EmptyCatalogGroup),
            26 => Some(Self::ReExportCycle),
            27 => Some(Self::SecurityClientServerLeak),
            28 => Some(Self::SecuritySink),
            _ => None,
        }
    }
}

/// A suppression directive parsed from a source comment.
///
/// # Examples
///
/// ```
/// use fallow_types::suppress::{Suppression, IssueKind};
///
/// // File-wide suppression (line 0, no specific kind)
/// let file_wide = Suppression { line: 0, comment_line: 1, kind: None };
/// assert_eq!(file_wide.line, 0);
///
/// // Line-specific suppression for unused exports
/// let line_suppress = Suppression {
///     line: 42,
///     comment_line: 41,
///     kind: Some(IssueKind::UnusedExport),
/// };
/// assert_eq!(line_suppress.kind, Some(IssueKind::UnusedExport));
/// ```
#[derive(Debug, Clone)]
pub struct Suppression {
    /// 1-based line this suppression applies to. 0 = file-wide suppression.
    pub line: u32,
    /// 1-based line where the suppression comment itself appears.
    /// For `fallow-ignore-next-line`, this is `line - 1`.
    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
    pub comment_line: u32,
    /// None = suppress all issue kinds on this line.
    pub kind: Option<IssueKind>,
}

/// A suppression token that did not parse to any known `IssueKind`.
///
/// Emitted alongside `Suppression` when a `// fallow-ignore-*` marker contains
/// a typo or an obsolete issue-kind name. The known tokens on the same marker
/// are recorded as normal `Suppression` entries; this struct preserves the
/// unknown token so the downstream `find_stale` pass can surface it as a
/// `StaleSuppression` finding with `kind_known: false`. Without this, the
/// entire suppression line would be discarded silently. See issue #449.
#[derive(Debug, Clone)]
pub struct UnknownSuppressionKind {
    /// 1-based line where the suppression comment itself appears.
    pub comment_line: u32,
    /// Whether the marker was `fallow-ignore-file` (`true`) or
    /// `fallow-ignore-next-line` (`false`).
    pub is_file_level: bool,
    /// The verbatim token from the marker that did not parse.
    pub token: String,
}

/// Canonical kebab-case names accepted by `IssueKind::parse`, including
/// documented plural aliases.
///
/// Used by `closest_known_kind_name` for Levenshtein "did you mean?" hints
/// when a suppression marker carries an unknown token. Keep in sync with the
/// `IssueKind::parse` match table above; the
/// `issue_kind_parse_covers_known_names` test asserts every entry round-trips.
pub const KNOWN_ISSUE_KIND_NAMES: &[&str] = &[
    "unused-file",
    "unused-export",
    "unused-type",
    "private-type-leak",
    "unused-dependency",
    "unused-dev-dependency",
    "unused-enum-member",
    "unused-class-member",
    "unresolved-import",
    "unlisted-dependency",
    "duplicate-export",
    "code-duplication",
    "circular-dependency",
    "circular-dependencies",
    "re-export-cycle",
    "re-export-cycles",
    "reexport-cycle",
    "reexport-cycles",
    "type-only-dependency",
    "test-only-dependency",
    "boundary-violation",
    "coverage-gaps",
    "feature-flag",
    "complexity",
    "stale-suppression",
    "unused-catalog-entry",
    "unused-catalog-entries",
    "empty-catalog-group",
    "empty-catalog-groups",
    "unresolved-catalog-reference",
    "unresolved-catalog-references",
    "unused-dependency-override",
    "unused-dependency-overrides",
    "misconfigured-dependency-override",
    "misconfigured-dependency-overrides",
    "security-client-server-leak",
    "security-sink",
];

/// Levenshtein edit distance between two ASCII-leaning strings.
///
/// Local duplicate of the config-crate helper (see
/// `crates/config/src/config/rules.rs::levenshtein`) so `fallow-types` can
/// compute "did you mean?" suggestions for unknown suppression tokens without
/// taking a dependency on `fallow-config`. Issue-kind names are short
/// (max ~33 chars) so allocation cost is negligible.
fn levenshtein(a: &str, b: &str) -> usize {
    let a_bytes = a.as_bytes();
    let b_bytes = b.as_bytes();
    let (a_len, b_len) = (a_bytes.len(), b_bytes.len());

    if a_len == 0 {
        return b_len;
    }
    if b_len == 0 {
        return a_len;
    }

    let mut prev: Vec<usize> = (0..=b_len).collect();
    let mut curr: Vec<usize> = vec![0; b_len + 1];

    for i in 1..=a_len {
        curr[0] = i;
        for j in 1..=b_len {
            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
        }
        std::mem::swap(&mut prev, &mut curr);
    }

    prev[b_len]
}

/// Find the closest known issue-kind name to `input` when it is plausibly a typo.
///
/// Returns the best match when the Levenshtein distance is at most 2 AND
/// the input is long enough that the match is not coincidental
/// (`input.len() / 2 > distance`). Returns `None` for completely novel
/// strings where a suggestion would be misleading.
#[must_use]
pub fn closest_known_kind_name(input: &str) -> Option<&'static str> {
    let input_lower = input.to_ascii_lowercase();
    let mut best: Option<(&'static str, usize)> = None;

    for &candidate in KNOWN_ISSUE_KIND_NAMES {
        let d = levenshtein(&input_lower, candidate);
        if best.is_none_or(|(_, b_dist)| d < b_dist) {
            best = Some((candidate, d));
        }
    }

    best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
        .map(|(name, _)| name)
}

const _: () = assert!(std::mem::size_of::<Suppression>() == 12);
const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);

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

    #[test]
    fn issue_kind_from_str_all_variants() {
        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
        assert_eq!(
            IssueKind::parse("unused-export"),
            Some(IssueKind::UnusedExport)
        );
        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
        assert_eq!(
            IssueKind::parse("private-type-leak"),
            Some(IssueKind::PrivateTypeLeak)
        );
        assert_eq!(
            IssueKind::parse("unused-dependency"),
            Some(IssueKind::UnusedDependency)
        );
        assert_eq!(
            IssueKind::parse("unused-dev-dependency"),
            Some(IssueKind::UnusedDevDependency)
        );
        assert_eq!(
            IssueKind::parse("unused-enum-member"),
            Some(IssueKind::UnusedEnumMember)
        );
        assert_eq!(
            IssueKind::parse("unused-class-member"),
            Some(IssueKind::UnusedClassMember)
        );
        assert_eq!(
            IssueKind::parse("unresolved-import"),
            Some(IssueKind::UnresolvedImport)
        );
        assert_eq!(
            IssueKind::parse("unlisted-dependency"),
            Some(IssueKind::UnlistedDependency)
        );
        assert_eq!(
            IssueKind::parse("duplicate-export"),
            Some(IssueKind::DuplicateExport)
        );
        assert_eq!(
            IssueKind::parse("code-duplication"),
            Some(IssueKind::CodeDuplication)
        );
        assert_eq!(
            IssueKind::parse("circular-dependency"),
            Some(IssueKind::CircularDependency)
        );
        assert_eq!(
            IssueKind::parse("circular-dependencies"),
            Some(IssueKind::CircularDependency)
        );
        assert_eq!(
            IssueKind::parse("type-only-dependency"),
            Some(IssueKind::TypeOnlyDependency)
        );
        assert_eq!(
            IssueKind::parse("test-only-dependency"),
            Some(IssueKind::TestOnlyDependency)
        );
        assert_eq!(
            IssueKind::parse("boundary-violation"),
            Some(IssueKind::BoundaryViolation)
        );
        assert_eq!(
            IssueKind::parse("coverage-gaps"),
            Some(IssueKind::CoverageGaps)
        );
        assert_eq!(
            IssueKind::parse("feature-flag"),
            Some(IssueKind::FeatureFlag)
        );
        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
        assert_eq!(
            IssueKind::parse("stale-suppression"),
            Some(IssueKind::StaleSuppression)
        );
        assert_eq!(
            IssueKind::parse("unused-catalog-entry"),
            Some(IssueKind::PnpmCatalogEntry)
        );
        assert_eq!(
            IssueKind::parse("unused-catalog-entries"),
            Some(IssueKind::PnpmCatalogEntry)
        );
        assert_eq!(
            IssueKind::parse("empty-catalog-group"),
            Some(IssueKind::EmptyCatalogGroup)
        );
        assert_eq!(
            IssueKind::parse("empty-catalog-groups"),
            Some(IssueKind::EmptyCatalogGroup)
        );
        assert_eq!(
            IssueKind::parse("unresolved-catalog-reference"),
            Some(IssueKind::UnresolvedCatalogReference)
        );
        assert_eq!(
            IssueKind::parse("unresolved-catalog-references"),
            Some(IssueKind::UnresolvedCatalogReference)
        );
        assert_eq!(
            IssueKind::parse("unused-dependency-override"),
            Some(IssueKind::UnusedDependencyOverride)
        );
        assert_eq!(
            IssueKind::parse("unused-dependency-overrides"),
            Some(IssueKind::UnusedDependencyOverride)
        );
        assert_eq!(
            IssueKind::parse("misconfigured-dependency-override"),
            Some(IssueKind::MisconfiguredDependencyOverride)
        );
        assert_eq!(
            IssueKind::parse("misconfigured-dependency-overrides"),
            Some(IssueKind::MisconfiguredDependencyOverride)
        );
        assert_eq!(
            IssueKind::parse("security-client-server-leak"),
            Some(IssueKind::SecurityClientServerLeak)
        );
        assert_eq!(
            IssueKind::parse("security-sink"),
            Some(IssueKind::SecuritySink)
        );
    }

    #[test]
    fn issue_kind_from_str_unknown() {
        assert_eq!(IssueKind::parse("foo"), None);
        assert_eq!(IssueKind::parse(""), None);
    }

    #[test]
    fn issue_kind_from_str_near_misses() {
        assert_eq!(IssueKind::parse("Unused-File"), None);
        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
        assert_eq!(IssueKind::parse("unused_file"), None);
        assert_eq!(IssueKind::parse("unused-files"), None);
    }

    #[test]
    fn discriminant_out_of_range() {
        assert_eq!(IssueKind::from_discriminant(0), None);
        assert_eq!(
            IssueKind::from_discriminant(28),
            Some(IssueKind::SecuritySink)
        );
        assert_eq!(IssueKind::from_discriminant(29), None);
        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
    }

    #[test]
    fn discriminant_roundtrip() {
        for kind in [
            IssueKind::UnusedFile,
            IssueKind::UnusedExport,
            IssueKind::UnusedType,
            IssueKind::PrivateTypeLeak,
            IssueKind::UnusedDependency,
            IssueKind::UnusedDevDependency,
            IssueKind::UnusedEnumMember,
            IssueKind::UnusedClassMember,
            IssueKind::UnresolvedImport,
            IssueKind::UnlistedDependency,
            IssueKind::DuplicateExport,
            IssueKind::CodeDuplication,
            IssueKind::CircularDependency,
            IssueKind::ReExportCycle,
            IssueKind::TypeOnlyDependency,
            IssueKind::TestOnlyDependency,
            IssueKind::BoundaryViolation,
            IssueKind::CoverageGaps,
            IssueKind::FeatureFlag,
            IssueKind::Complexity,
            IssueKind::StaleSuppression,
            IssueKind::PnpmCatalogEntry,
            IssueKind::EmptyCatalogGroup,
            IssueKind::UnresolvedCatalogReference,
            IssueKind::UnusedDependencyOverride,
            IssueKind::MisconfiguredDependencyOverride,
            IssueKind::SecurityClientServerLeak,
            IssueKind::SecuritySink,
        ] {
            assert_eq!(
                IssueKind::from_discriminant(kind.to_discriminant()),
                Some(kind)
            );
        }
        assert_eq!(IssueKind::from_discriminant(0), None);
        assert_eq!(IssueKind::from_discriminant(29), None);
    }

    #[test]
    fn discriminant_values_are_unique() {
        let all_kinds = [
            IssueKind::UnusedFile,
            IssueKind::UnusedExport,
            IssueKind::UnusedType,
            IssueKind::PrivateTypeLeak,
            IssueKind::UnusedDependency,
            IssueKind::UnusedDevDependency,
            IssueKind::UnusedEnumMember,
            IssueKind::UnusedClassMember,
            IssueKind::UnresolvedImport,
            IssueKind::UnlistedDependency,
            IssueKind::DuplicateExport,
            IssueKind::CodeDuplication,
            IssueKind::CircularDependency,
            IssueKind::ReExportCycle,
            IssueKind::TypeOnlyDependency,
            IssueKind::TestOnlyDependency,
            IssueKind::BoundaryViolation,
            IssueKind::CoverageGaps,
            IssueKind::FeatureFlag,
            IssueKind::Complexity,
            IssueKind::StaleSuppression,
            IssueKind::PnpmCatalogEntry,
            IssueKind::EmptyCatalogGroup,
            IssueKind::UnresolvedCatalogReference,
            IssueKind::UnusedDependencyOverride,
            IssueKind::MisconfiguredDependencyOverride,
            IssueKind::SecurityClientServerLeak,
            IssueKind::SecuritySink,
        ];
        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
        let mut sorted = discriminants.clone();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(
            discriminants.len(),
            sorted.len(),
            "discriminant values must be unique"
        );
    }

    #[test]
    fn discriminant_starts_at_one() {
        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
    }

    #[test]
    fn suppression_line_zero_is_file_wide() {
        let s = Suppression {
            line: 0,
            comment_line: 1,
            kind: None,
        };
        assert_eq!(s.line, 0);
        assert!(s.kind.is_none());
    }

    #[test]
    fn suppression_with_specific_kind_and_line() {
        let s = Suppression {
            line: 42,
            comment_line: 41,
            kind: Some(IssueKind::UnusedExport),
        };
        assert_eq!(s.line, 42);
        assert_eq!(s.comment_line, 41);
        assert_eq!(s.kind, Some(IssueKind::UnusedExport));
    }

    #[test]
    fn known_issue_kind_names_parses_each_entry() {
        for &name in KNOWN_ISSUE_KIND_NAMES {
            assert!(
                IssueKind::parse(name).is_some(),
                "KNOWN_ISSUE_KIND_NAMES contains '{name}' but IssueKind::parse rejects it"
            );
        }
    }

    #[test]
    fn closest_known_kind_name_finds_near_misses() {
        assert_eq!(
            closest_known_kind_name("unused-exports"),
            Some("unused-export")
        );
        assert_eq!(closest_known_kind_name("unused-files"), Some("unused-file"));
        assert_eq!(closest_known_kind_name("complxity"), Some("complexity"));
    }

    #[test]
    fn closest_known_kind_name_rejects_novel_strings() {
        assert_eq!(closest_known_kind_name("xyzzy"), None);
        assert_eq!(closest_known_kind_name("foo"), None);
        assert_eq!(closest_known_kind_name(""), None);
    }

    #[test]
    fn closest_known_kind_name_skips_exact_match() {
        assert_eq!(closest_known_kind_name("unused-export"), None);
    }
}
