//! Typed envelope wrappers for the simple 1:1 dead-code findings whose
//! actions are entirely determined by the wrapper type (no per-instance
//! discriminants beyond what the bare finding already exposes).
//!
//! Each wrapper flattens the bare finding via `#[serde(flatten)]` so the
//! wire shape matches the previous `actions`-grafted output byte-for-byte.
//! `actions` is populated at construction time via each wrapper's
//! `with_actions` constructor and replaces the per-finding `inject_actions`
//! post-pass in `crates/cli/src/report/json.rs`. `introduced` carries the optional audit
//! breadcrumb that `crates/cli/src/audit.rs::annotate_issue_array` inserts
//! into the JSON object via `map.insert`; the wrapper-level field stays
//! `None` when serialized directly from Rust and is set by the audit pass
//! only when the issue was introduced relative to the merge-base.
//!
//! All nine wrappers ship with `IssueAction` arrays today; they pay the
//! `serde_json` dependency cost because `IssueAction` transitively
//! references `AddToConfigValue::RuleObject(serde_json::Map<...>)`. The
//! variants the wrappers actually emit (`Fix`, `SuppressLine`,
//! `SuppressFile`) are small, but reusing the existing enum keeps the
//! wire-shape contract identical to the legacy post-pass.
//!
//! `introduced` is typed as `Option<AuditIntroduced>` (transparent newtype
//! over `bool`) so the regenerated schema renders the field via
//! `$ref: #/definitions/AuditIntroduced`, matching the reference the prior
//! post-pass augmentation graft used. The audit pass continues to inject a
//! bare bool via `map.insert("introduced", ...)`; serde reads it back into
//! `AuditIntroduced` transparently. The field stays absent at the wire when
//! `None` (`skip_serializing_if`).

use serde::Serialize;

use crate::envelope::AuditIntroduced;
use crate::output::{
    AddToConfigAction, AddToConfigKind, AddToConfigValue, FixAction, FixActionType,
    IgnoreExportsRule, IssueAction, SuppressFileAction, SuppressFileKind, SuppressLineAction,
    SuppressLineKind, SuppressLineScope,
};
use crate::results::{
    BoundaryViolation, CircularDependency, DependencyOverrideSource, DuplicateExport,
    EmptyCatalogGroup, MisconfiguredDependencyOverride, PrivateTypeLeak, ReExportCycle,
    ReExportCycleKind, TestOnlyDependency, TypeOnlyDependency, UnlistedDependency,
    UnresolvedCatalogReference, UnresolvedImport, UnusedCatalogEntry, UnusedDependency,
    UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedMember,
};

/// Shared note for the `duplicate-exports` fix action. Mirrors the const used
/// by the human report (see `crates/cli/src/report/shared.rs`); kept here so
/// the wire-format builder reads from the same source of truth.
pub const NAMESPACE_BARREL_HINT: &str = "If every location is the sole `index.*` of its directory, this is likely an intentional namespace-barrel API. Prefer adding these files to `ignoreExports` over removing exports.";

/// JSON Schema fragment URL for the `add-to-config` `ignoreExports` action's
/// `value` payload. Pinned to the main branch so users browsing the action
/// value can navigate directly to the rule shape.
const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";

/// JSON Schema fragment URL for the `ignoreCatalogReferences` rule items
/// referenced by `add-to-config` actions on `unresolved-catalog-references`.
const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";

/// JSON Schema fragment URL for the `ignoreDependencyOverrides` rule items
/// referenced by `add-to-config` actions on both the unused- and
/// misconfigured-override findings.
const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";

/// Wire-shape envelope for an [`UnusedFile`] finding. The bare finding
/// flattens in via `#[serde(flatten)]`, with a typed `actions` array
/// populated at construction time and the audit-pass `introduced` flag
/// attached as an optional sibling.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedFileFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub file: UnusedFile,
    /// Suggested next steps: a `delete-file` primary and a `suppress-file`
    /// secondary. Always emitted (possibly empty for forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base. `None` when serialized directly from Rust.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedFileFinding {
    /// Build the wrapper from a raw [`UnusedFile`], computing the typed
    /// `actions` array inline. `introduced` stays `None` and is set later
    /// by `annotate_dead_code_json` if the audit pass runs.
    #[must_use]
    pub fn with_actions(file: UnusedFile) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::DeleteFile,
                auto_fixable: false,
                description: "Delete this file".to_string(),
                note: Some(
                    "File deletion may remove runtime functionality not visible to static analysis"
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressFile(SuppressFileAction {
                kind: SuppressFileKind::SuppressFile,
                auto_fixable: false,
                description: "Suppress with a file-level comment at the top of the file"
                    .to_string(),
                comment: "// fallow-ignore-file unused-file".to_string(),
            }),
        ];
        Self {
            file,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for a [`PrivateTypeLeak`] finding. Mirrors
/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
/// `actions` array (`export-type` primary plus `suppress-line` secondary).
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PrivateTypeLeakFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub leak: PrivateTypeLeak,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl PrivateTypeLeakFinding {
    /// Build the wrapper from a raw [`PrivateTypeLeak`].
    #[must_use]
    pub fn with_actions(leak: PrivateTypeLeak) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::ExportType,
                auto_fixable: false,
                description: "Export the referenced private type by name".to_string(),
                note: Some(
                    "Keep the type exported while it is part of a public signature".to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with an inline comment above the line".to_string(),
                comment: "// fallow-ignore-next-line private-type-leak".to_string(),
                scope: None,
            }),
        ];
        Self {
            leak,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnresolvedImport`] finding. Mirrors
/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
/// `actions` array (`resolve-import` primary plus `suppress-line`
/// secondary).
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnresolvedImportFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub import: UnresolvedImport,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnresolvedImportFinding {
    /// Build the wrapper from a raw [`UnresolvedImport`].
    #[must_use]
    pub fn with_actions(import: UnresolvedImport) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::ResolveImport,
                auto_fixable: false,
                description: "Fix the import specifier or install the missing module".to_string(),
                note: Some(
                    "Verify the module path and check tsconfig paths configuration".to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with an inline comment above the line".to_string(),
                comment: "// fallow-ignore-next-line unresolved-import".to_string(),
                scope: None,
            }),
        ];
        Self {
            import,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for a [`CircularDependency`] finding. Mirrors
/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
/// `actions` array (`refactor-cycle` primary plus `suppress-line`
/// secondary).
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CircularDependencyFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub cycle: CircularDependency,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl CircularDependencyFinding {
    /// Build the wrapper from a raw [`CircularDependency`].
    #[must_use]
    pub fn with_actions(cycle: CircularDependency) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RefactorCycle,
                auto_fixable: false,
                description: "Extract shared logic into a separate module to break the cycle"
                    .to_string(),
                note: Some(
                    "Circular imports can cause initialization issues and make code harder to reason about"
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with an inline comment above the line".to_string(),
                comment: "// fallow-ignore-next-line circular-dependency".to_string(),
                scope: None,
            }),
        ];
        Self {
            cycle,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for a [`ReExportCycle`] finding. Mirrors
/// [`CircularDependencyFinding`]: flattens the bare finding and carries a
/// typed `actions` array (`refactor-re-export-cycle` informational primary
/// plus `suppress-file` secondary; cycles are file-scoped so a single
/// file-level suppression on the alphabetically-first member breaks the
/// cycle, and no `// fallow-ignore-next-line` form makes sense because the
/// diagnostic is anchored at line 1 col 0 of each member).
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ReExportCycleFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub cycle: ReExportCycle,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl ReExportCycleFinding {
    /// Build the wrapper from a raw [`ReExportCycle`].
    ///
    /// The `SuppressFile` action targets the alphabetically-first member
    /// (`cycle.files[0]`; the `files` Vec is already sorted at graph layer);
    /// for multi-node cycles the description names the other members so
    /// consumers see context for why one file-level suppression suffices.
    #[must_use]
    pub fn with_actions(cycle: ReExportCycle) -> Self {
        // The description is a path-free hint about the suppression's
        // structural effect; the cycle's member list already ships in the
        // sibling `files` field, so consumers can correlate without
        // re-reading the description (and absolute paths cannot leak in
        // here, which the wrapper has no root-prefix context to strip).
        let suppress_description = match cycle.kind {
            ReExportCycleKind::SelfLoop => {
                "Suppress with a file-level comment at the top of this file. \
                 The cycle is a self-loop, so the suppression covers the entire finding."
                    .to_string()
            }
            ReExportCycleKind::MultiNode => {
                "Suppress with a file-level comment at the top of this file. \
                 One suppression on any member breaks the cycle for every member \
                 (see the sibling `files` array)."
                    .to_string()
            }
        };
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RefactorReExportCycle,
                auto_fixable: false,
                description: "Remove one `export * from` (or `export { ... } from`) \
                              statement on any one member to break the cycle"
                    .to_string(),
                note: Some(
                    "Re-export cycles are structurally a no-op: chain propagation through \
                     the loop never reaches a terminating module, so imports from any member \
                     may silently come up empty."
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressFile(SuppressFileAction {
                kind: SuppressFileKind::SuppressFile,
                auto_fixable: false,
                description: suppress_description,
                comment: "// fallow-ignore-file re-export-cycle".to_string(),
            }),
        ];
        Self {
            cycle,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for a [`BoundaryViolation`] finding. Mirrors
/// [`UnusedFileFinding`]: flattens the bare finding and carries a typed
/// `actions` array (`refactor-boundary` primary plus `suppress-line`
/// secondary).
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BoundaryViolationFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub violation: BoundaryViolation,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl BoundaryViolationFinding {
    /// Build the wrapper from a raw [`BoundaryViolation`].
    #[must_use]
    pub fn with_actions(violation: BoundaryViolation) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RefactorBoundary,
                auto_fixable: false,
                description: "Move the import through an allowed zone or restructure the dependency"
                    .to_string(),
                note: Some(
                    "This import crosses an architecture boundary that is not permitted by the configured rules"
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with an inline comment above the line".to_string(),
                comment: "// fallow-ignore-next-line boundary-violation".to_string(),
                scope: None,
            }),
        ];
        Self {
            violation,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
/// `unused_exports` key. Same Rust struct as [`UnusedTypeFinding`], with a
/// different fix description so consumers can tell value-export from
/// type-export removal at the action level.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedExportFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub export: UnusedExport,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedExportFinding {
    /// Build the wrapper. When `export.is_re_export` is true, the fix
    /// action's `note` warns about possible public-API surface; otherwise
    /// `note` is absent on the fix action.
    #[must_use]
    pub fn with_actions(export: UnusedExport) -> Self {
        let note = if export.is_re_export {
            Some(
                "This finding originates from a re-export; verify it is not part of your public API before removing"
                    .to_string(),
            )
        } else {
            None
        };
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RemoveExport,
                auto_fixable: true,
                description: "Remove the unused export from the public API".to_string(),
                note,
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with an inline comment above the line".to_string(),
                comment: "// fallow-ignore-next-line unused-export".to_string(),
                scope: None,
            }),
        ];
        Self {
            export,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnusedExport`] finding consumed under the
/// `unused_types` key. Wraps the same bare [`UnusedExport`] struct as
/// [`UnusedExportFinding`] but emits a fix action targeted at type-only
/// declarations, with the same `is_re_export`-aware note swap.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedTypeFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub export: UnusedExport,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedTypeFinding {
    /// Build the wrapper. `is_re_export` swaps the fix note the same way as
    /// [`UnusedExportFinding::with_actions`].
    #[must_use]
    pub fn with_actions(export: UnusedExport) -> Self {
        let note = if export.is_re_export {
            Some(
                "This finding originates from a re-export; verify it is not part of your public API before removing"
                    .to_string(),
            )
        } else {
            None
        };
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RemoveExport,
                auto_fixable: true,
                description:
                    "Remove the `export` (or `export type`) keyword from the type declaration"
                        .to_string(),
                note,
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with an inline comment above the line".to_string(),
                comment: "// fallow-ignore-next-line unused-type".to_string(),
                scope: None,
            }),
        ];
        Self {
            export,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
/// `unused_enum_members` key.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedEnumMemberFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub member: UnusedMember,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedEnumMemberFinding {
    /// Build the wrapper from a raw [`UnusedMember`].
    #[must_use]
    pub fn with_actions(member: UnusedMember) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RemoveEnumMember,
                auto_fixable: true,
                description: "Remove this enum member".to_string(),
                note: None,
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with an inline comment above the line".to_string(),
                comment: "// fallow-ignore-next-line unused-enum-member".to_string(),
                scope: None,
            }),
        ];
        Self {
            member,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnusedMember`] finding consumed under the
/// `unused_class_members` key. Same Rust struct as
/// [`UnusedEnumMemberFinding`]; the fix action and suppress comment carry
/// the class-member kebab-case identifier instead.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedClassMemberFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub member: UnusedMember,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedClassMemberFinding {
    /// Build the wrapper from a raw [`UnusedMember`]. Class-member fixes
    /// are not auto-applied (members can be used via dependency injection
    /// or decorators), so `auto_fixable` is `false` and a context note is
    /// attached.
    #[must_use]
    pub fn with_actions(member: UnusedMember) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RemoveClassMember,
                auto_fixable: false,
                description: "Remove this class member".to_string(),
                note: Some(
                    "Class member may be used via dependency injection or decorators".to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with an inline comment above the line".to_string(),
                comment: "// fallow-ignore-next-line unused-class-member".to_string(),
                scope: None,
            }),
        ];
        Self {
            member,
            actions,
            introduced: None,
        }
    }
}

/// Build the `IssueAction` vec for the three `unused_dependencies`,
/// `unused_dev_dependencies`, `unused_optional_dependencies` views over the
/// same bare [`UnusedDependency`] struct. Each wrapper differs only in the
/// `package_json_location` string (`"dependencies"` / `"devDependencies"` /
/// `"optionalDependencies"`) baked into the fix-action description and in
/// the `suppress_issue_kind` used by the inline-suppress comment. All three
/// share the cross-workspace swap (when `dep.used_in_workspaces` is
/// non-empty the primary fix flips from `remove-dependency` to
/// `move-dependency` because the dep is imported by ANOTHER workspace and
/// `fallow fix` cannot safely remove it).
fn build_unused_dependency_actions(
    dep: &UnusedDependency,
    package_json_location: &str,
    suppress_issue_kind: &str,
) -> Vec<IssueAction> {
    let mut actions = Vec::with_capacity(2);
    let cross_workspace = !dep.used_in_workspaces.is_empty();
    actions.push(if cross_workspace {
        IssueAction::Fix(FixAction {
            kind: FixActionType::MoveDependency,
            auto_fixable: false,
            description: "Move this dependency to the workspace package.json that imports it"
                .to_string(),
            note: Some(
                "fallow fix will not remove dependencies that are imported by another workspace"
                    .to_string(),
            ),
            available_in_catalogs: None,
            suggested_target: None,
        })
    } else {
        IssueAction::Fix(FixAction {
            kind: FixActionType::RemoveDependency,
            auto_fixable: true,
            description: format!("Remove from {package_json_location} in package.json"),
            note: None,
            available_in_catalogs: None,
            suggested_target: None,
        })
    });
    actions.push(build_ignore_dependencies_suppress_action(
        &dep.package_name,
        suppress_issue_kind,
    ));
    actions
}

/// Build the standard `add-to-config` `ignoreDependencies` suppress action
/// for any finding whose primary key is a package name. Used by the four
/// dependency-family wrappers (unused / unlisted / type-only / test-only).
/// The `_suppress_issue_kind` argument is currently unused; the pre-2.76
/// `inject_actions` post-pass also did not embed the issue kind in this
/// shape (no inline `// fallow-ignore-next-line ...` comment because the
/// finding is anchored at a package.json line, not at a source-file line).
fn build_ignore_dependencies_suppress_action(
    package_name: &str,
    _suppress_issue_kind: &str,
) -> IssueAction {
    IssueAction::AddToConfig(AddToConfigAction {
        kind: AddToConfigKind::AddToConfig,
        auto_fixable: false,
        description: format!("Add \"{package_name}\" to ignoreDependencies in fallow config"),
        config_key: "ignoreDependencies".to_string(),
        value: AddToConfigValue::Scalar(package_name.to_string()),
        value_schema: Some(
            "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items"
                .to_string(),
        ),
    })
}

/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
/// the `unused_dependencies` key (production deps). Flattens the bare
/// finding; the typed `actions` array carries either a `remove-dependency`
/// or `move-dependency` primary depending on
/// `inner.used_in_workspaces`.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedDependencyFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub dep: UnusedDependency,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedDependencyFinding {
    /// Build the wrapper. Switches the primary fix from `remove-dependency`
    /// to `move-dependency` when the dep is imported by another workspace.
    #[must_use]
    pub fn with_actions(dep: UnusedDependency) -> Self {
        let actions = build_unused_dependency_actions(&dep, "dependencies", "unused-dependency");
        Self {
            dep,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
/// the `unused_dev_dependencies` key. Same bare struct as
/// [`UnusedDependencyFinding`]; the fix description points at
/// `devDependencies` and the suppress comment uses
/// `unused-dev-dependency`.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedDevDependencyFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub dep: UnusedDependency,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedDevDependencyFinding {
    /// Build the wrapper.
    #[must_use]
    pub fn with_actions(dep: UnusedDependency) -> Self {
        let actions =
            build_unused_dependency_actions(&dep, "devDependencies", "unused-dev-dependency");
        Self {
            dep,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnusedDependency`] finding consumed under
/// the `unused_optional_dependencies` key. Same bare struct as
/// [`UnusedDependencyFinding`]; the fix description points at
/// `optionalDependencies`. Reuses the `unused-dependency` suppress
/// `IssueKind` because there is no dedicated variant for optional deps.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedOptionalDependencyFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub dep: UnusedDependency,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedOptionalDependencyFinding {
    /// Build the wrapper.
    #[must_use]
    pub fn with_actions(dep: UnusedDependency) -> Self {
        let actions =
            build_unused_dependency_actions(&dep, "optionalDependencies", "unused-dependency");
        Self {
            dep,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnlistedDependency`] finding. Carries an
/// `install-dependency` primary (non-auto-fixable) plus the standard
/// `ignoreDependencies` config suppress.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnlistedDependencyFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub dep: UnlistedDependency,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnlistedDependencyFinding {
    /// Build the wrapper.
    #[must_use]
    pub fn with_actions(dep: UnlistedDependency) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::InstallDependency,
                auto_fixable: false,
                description: "Add this package to dependencies in package.json".to_string(),
                note: Some(
                    "Verify this package should be a direct dependency before adding".to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            build_ignore_dependencies_suppress_action(&dep.package_name, "unlisted-dependency"),
        ];
        Self {
            dep,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for a [`TypeOnlyDependency`] finding. Carries a
/// `move-to-dev` primary plus the standard `ignoreDependencies` config
/// suppress.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TypeOnlyDependencyFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub dep: TypeOnlyDependency,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl TypeOnlyDependencyFinding {
    /// Build the wrapper.
    #[must_use]
    pub fn with_actions(dep: TypeOnlyDependency) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::MoveToDev,
                auto_fixable: false,
                description: "Move to devDependencies (only type imports are used)".to_string(),
                note: Some(
                    "Type imports are erased at runtime so this dependency is not needed in production"
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            build_ignore_dependencies_suppress_action(&dep.package_name, "type-only-dependency"),
        ];
        Self {
            dep,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for a [`TestOnlyDependency`] finding. Carries a
/// `move-to-dev` primary (different prose than [`TypeOnlyDependencyFinding`])
/// plus the standard `ignoreDependencies` config suppress.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TestOnlyDependencyFinding {
    /// The underlying dead-code entry.
    #[serde(flatten)]
    pub dep: TestOnlyDependency,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl TestOnlyDependencyFinding {
    /// Build the wrapper.
    #[must_use]
    pub fn with_actions(dep: TestOnlyDependency) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::MoveToDev,
                auto_fixable: false,
                description: "Move to devDependencies (only test files import this)".to_string(),
                note: Some(
                    "Only test files import this package so it does not need to be a production dependency"
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            build_ignore_dependencies_suppress_action(&dep.package_name, "test-only-dependency"),
        ];
        Self {
            dep,
            actions,
            introduced: None,
        }
    }
}

// ── Catalog / dep-override family ───────────────────────────────
//
// These six wrappers replace the legacy `inject_actions` post-pass in
// `crates/cli/src/report/json.rs` for the catalog and dependency-override
// findings. Each `with_actions(...)` builds the typed `actions` array
// directly from the inner struct (and any per-call context such as
// `config_fixable`), so the wire shape is identical to the pre-2.76
// post-pass output but the Rust compiler now owns the action contract.

/// Wire-shape envelope for a [`DuplicateExport`] finding. Carries up to
/// three actions in position-locked order: an `add-to-config` `ignoreExports`
/// snippet (only when `locations[]` carries at least one path) followed by
/// the `remove-duplicate` fix and the multi-location suppress.
///
/// The `add-to-config` action sits at position 0 because the documented
/// primary slot points at the safe, non-destructive path: the shadcn /
/// Radix / bits-ui namespace-barrel case where every `index.*` reexports
/// the directory's neighbours. The `remove-duplicate` fix stays as the
/// secondary so consumers that pattern-match on `actions[0].type` for
/// "primary fix" never propose deletion of an intentional barrel surface.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DuplicateExportFinding {
    /// The underlying finding.
    #[serde(flatten)]
    pub export: DuplicateExport,
    /// Suggested next steps. Always emitted (possibly empty for
    /// forward-compat).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl DuplicateExportFinding {
    /// Build the wrapper with the `add-to-config` action's `auto_fixable`
    /// defaulting to `false`. The CLI's `build_json_with_config_fixable`
    /// path layers the actual `config_fixable` signal via
    /// [`Self::set_config_fixable`] right before serialization (the
    /// fix-applier readiness check lives in `fallow-cli::fix` and is not
    /// reachable from the analyzer layer where wrappers are first built).
    /// Embedders that build `AnalysisResults` directly and never route
    /// through the CLI's JSON path keep the conservative default.
    #[must_use]
    pub fn with_actions(export: DuplicateExport) -> Self {
        let mut actions: Vec<IssueAction> = Vec::with_capacity(3);

        if let Some(rules) = build_duplicate_exports_ignore_rules(&export) {
            actions.push(IssueAction::AddToConfig(AddToConfigAction {
                kind: AddToConfigKind::AddToConfig,
                auto_fixable: false,
                description: "Add an ignoreExports rule so these files are excluded from duplicate-export grouping (use when this duplication is an intentional namespace-barrel API).".to_string(),
                config_key: "ignoreExports".to_string(),
                value: AddToConfigValue::ExportsRules(rules),
                value_schema: Some(IGNORE_EXPORTS_VALUE_SCHEMA.to_string()),
            }));
        }

        actions.push(IssueAction::Fix(FixAction {
            kind: FixActionType::RemoveDuplicate,
            auto_fixable: false,
            description: "Keep one canonical export location and remove the others".to_string(),
            note: Some(NAMESPACE_BARREL_HINT.to_string()),
            available_in_catalogs: None,
            suggested_target: None,
        }));

        actions.push(IssueAction::SuppressLine(SuppressLineAction {
            kind: SuppressLineKind::SuppressLine,
            auto_fixable: false,
            description: "Suppress with an inline comment above the line".to_string(),
            comment: "// fallow-ignore-next-line duplicate-export".to_string(),
            scope: Some(SuppressLineScope::PerLocation),
        }));

        Self {
            export,
            actions,
            introduced: None,
        }
    }

    /// Update the position-0 `add-to-config` action's `auto_fixable` flag.
    /// Idempotent and a no-op when position 0 is not an `add-to-config`
    /// action (happens when the finding has no locations). Called by the
    /// CLI's JSON serializer with the result of
    /// `crate::fix::is_config_fixable` before emitting bytes.
    pub fn set_config_fixable(&mut self, fixable: bool) {
        if let Some(IssueAction::AddToConfig(action)) = self.actions.first_mut() {
            action.auto_fixable = fixable;
        }
    }
}

/// Build a paste-ready `ignoreExports` config value from a duplicate-export
/// finding's locations. Returns one `{ file, exports: ["*"] }` entry per
/// distinct file in insertion order. `None` when no locations carry a path.
fn build_duplicate_exports_ignore_rules(
    export: &DuplicateExport,
) -> Option<Vec<IgnoreExportsRule>> {
    let mut entries: Vec<IgnoreExportsRule> = Vec::with_capacity(export.locations.len());
    for loc in &export.locations {
        // Normalize separators to forward slashes so pasting the action value
        // into `.fallowrc.json` produces a portable rule. On Windows
        // `to_string_lossy` preserves backslashes, which the old
        // `inject_actions` post-pass implicitly normalized because it read
        // the path AFTER `strip_root_prefix` had already run through
        // `normalize_uri`; the typed wrapper builds the value before
        // serialization, so the normalization has to be explicit here.
        let path = loc.path.to_string_lossy().replace('\\', "/");
        if path.is_empty() {
            continue;
        }
        if entries.iter().any(|existing| existing.file == path) {
            continue;
        }
        entries.push(IgnoreExportsRule {
            file: path,
            exports: vec!["*".to_string()],
        });
    }
    if entries.is_empty() {
        None
    } else {
        Some(entries)
    }
}

/// Wire-shape envelope for an [`UnusedCatalogEntry`] finding. Per-instance
/// `auto_fixable` flips to `false` when `hardcoded_consumers` is non-empty:
/// the entry cannot be removed safely while a workspace package still pins
/// the same package via a hardcoded version range.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedCatalogEntryFinding {
    /// The underlying finding.
    #[serde(flatten)]
    pub entry: UnusedCatalogEntry,
    /// Suggested next steps. Always emitted.
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedCatalogEntryFinding {
    /// Build the wrapper. Per-instance `auto_fixable` is `true` only when
    /// `hardcoded_consumers` is empty; otherwise `fallow fix` skips the
    /// entry to avoid breaking `pnpm install` on the holdout consumer.
    #[must_use]
    pub fn with_actions(entry: UnusedCatalogEntry) -> Self {
        let auto_fixable = entry.hardcoded_consumers.is_empty();
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RemoveCatalogEntry,
                auto_fixable,
                description: "Remove the entry from pnpm-workspace.yaml".to_string(),
                note: Some(
                    "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing"
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with a YAML comment above the line".to_string(),
                comment: "# fallow-ignore-next-line unused-catalog-entry".to_string(),
                scope: None,
            }),
        ];
        Self {
            entry,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`EmptyCatalogGroup`] finding. Carries a
/// straightforward `remove-empty-catalog-group` primary plus a YAML-comment
/// suppress.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EmptyCatalogGroupFinding {
    /// The underlying finding.
    #[serde(flatten)]
    pub group: EmptyCatalogGroup,
    /// Suggested next steps. Always emitted.
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl EmptyCatalogGroupFinding {
    /// Build the wrapper.
    #[must_use]
    pub fn with_actions(group: EmptyCatalogGroup) -> Self {
        let actions = vec![
            IssueAction::Fix(FixAction {
                kind: FixActionType::RemoveEmptyCatalogGroup,
                auto_fixable: true,
                description: "Remove the empty named catalog group from pnpm-workspace.yaml"
                    .to_string(),
                note: Some(
                    "Only named groups under `catalogs:` are flagged; the top-level `catalog:` hook is intentionally ignored"
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            }),
            IssueAction::SuppressLine(SuppressLineAction {
                kind: SuppressLineKind::SuppressLine,
                auto_fixable: false,
                description: "Suppress with a YAML comment above the line".to_string(),
                comment: "# fallow-ignore-next-line empty-catalog-group".to_string(),
                scope: None,
            }),
        ];
        Self {
            group,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnresolvedCatalogReference`] finding. The
/// primary action at position 0 discriminates on `available_in_catalogs`:
/// `add-catalog-entry` when the array is empty (no other catalog declares
/// the package), or `update-catalog-reference` when at least one
/// alternative exists. When exactly one alternative exists, the action
/// also carries `suggested_target` so deterministic agents can land the
/// edit without picking from a list.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnresolvedCatalogReferenceFinding {
    /// The underlying finding.
    #[serde(flatten)]
    pub reference: UnresolvedCatalogReference,
    /// Suggested next steps. Always emitted; position 0 is the discriminated
    /// primary (see struct docs).
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnresolvedCatalogReferenceFinding {
    /// Build the wrapper. The discriminator at position 0 is the
    /// `add-catalog-entry` vs `update-catalog-reference` pick documented on
    /// the struct.
    #[must_use]
    pub fn with_actions(reference: UnresolvedCatalogReference) -> Self {
        // Normalize separators to forward slashes so the
        // `ignoreCatalogReferences.consumer` action value is portable when
        // pasted into a Windows-authored config. See
        // `build_duplicate_exports_ignore_rules` for the same pattern.
        let consumer_path = reference.path.to_string_lossy().replace('\\', "/");
        let primary = if reference.available_in_catalogs.is_empty() {
            IssueAction::Fix(FixAction {
                kind: FixActionType::AddCatalogEntry,
                auto_fixable: false,
                description: format!(
                    "Add `{}` to the `{}` catalog in pnpm-workspace.yaml",
                    reference.entry_name, reference.catalog_name
                ),
                note: Some(
                    "Pin a version that satisfies the consumer's import; no other catalog declares this package today"
                        .to_string(),
                ),
                available_in_catalogs: None,
                suggested_target: None,
            })
        } else {
            let available = reference.available_in_catalogs.clone();
            let suggested_target = (available.len() == 1).then(|| available[0].clone());
            IssueAction::Fix(FixAction {
                kind: FixActionType::UpdateCatalogReference,
                auto_fixable: false,
                description: format!(
                    "Switch the reference from `catalog:{}` to a catalog that declares `{}`",
                    reference.catalog_name, reference.entry_name
                ),
                note: None,
                available_in_catalogs: Some(available),
                suggested_target,
            })
        };

        let fallback = IssueAction::Fix(FixAction {
            kind: FixActionType::RemoveCatalogReference,
            auto_fixable: false,
            description:
                "Remove the catalog reference and pin a hardcoded version in package.json"
                    .to_string(),
            note: Some(
                "Use only when neither another catalog declares the package nor the named catalog should grow to include it"
                    .to_string(),
            ),
            available_in_catalogs: None,
            suggested_target: None,
        });

        let mut suppress_value = serde_json::Map::new();
        suppress_value.insert(
            "package".to_string(),
            serde_json::Value::String(reference.entry_name.clone()),
        );
        suppress_value.insert(
            "catalog".to_string(),
            serde_json::Value::String(reference.catalog_name.clone()),
        );
        suppress_value.insert(
            "consumer".to_string(),
            serde_json::Value::String(consumer_path),
        );
        let suppress = IssueAction::AddToConfig(AddToConfigAction {
            kind: AddToConfigKind::AddToConfig,
            auto_fixable: false,
            description: "Suppress this reference via ignoreCatalogReferences in fallow config (use when the catalog edit is intentionally landing in a separate PR or the package is a placeholder).".to_string(),
            config_key: "ignoreCatalogReferences".to_string(),
            value: AddToConfigValue::RuleObject(suppress_value),
            value_schema: Some(IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA.to_string()),
        });

        Self {
            reference,
            actions: vec![primary, fallback, suppress],
            introduced: None,
        }
    }
}

/// Wire-shape envelope for an [`UnusedDependencyOverride`] finding. Carries
/// a `remove-dependency-override` primary plus an `add-to-config`
/// `ignoreDependencyOverrides` suppress scoped to the target package and
/// declaration source.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedDependencyOverrideFinding {
    /// The underlying finding.
    #[serde(flatten)]
    pub entry: UnusedDependencyOverride,
    /// Suggested next steps. Always emitted.
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl UnusedDependencyOverrideFinding {
    /// Build the wrapper.
    #[must_use]
    pub fn with_actions(entry: UnusedDependencyOverride) -> Self {
        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
        actions.push(IssueAction::Fix(FixAction {
            kind: FixActionType::RemoveDependencyOverride,
            auto_fixable: false,
            description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides"
                .to_string(),
            note: Some(
                "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)"
                    .to_string(),
            ),
            available_in_catalogs: None,
            suggested_target: None,
        }));

        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
            Some(&entry.target_package),
            &entry.raw_key,
            entry.source,
        ) {
            actions.push(suppress);
        }

        Self {
            entry,
            actions,
            introduced: None,
        }
    }
}

/// Wire-shape envelope for a [`MisconfiguredDependencyOverride`] finding.
/// Carries a `fix-dependency-override` primary plus the conditional
/// `add-to-config` `ignoreDependencyOverrides` suppress (skipped when both
/// `target_package` and `raw_key` are empty, since the rule matcher keys on
/// a non-empty package name).
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct MisconfiguredDependencyOverrideFinding {
    /// The underlying finding.
    #[serde(flatten)]
    pub entry: MisconfiguredDependencyOverride,
    /// Suggested next steps. Always emitted.
    pub actions: Vec<IssueAction>,
    /// Set by the audit pass when this finding is introduced relative to
    /// the merge-base.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub introduced: Option<AuditIntroduced>,
}

impl MisconfiguredDependencyOverrideFinding {
    /// Build the wrapper. The suppress action is omitted when neither
    /// `target_package` (set on `EmptyValue` cases) nor `raw_key` provides a
    /// non-empty package name; an `ignoreDependencyOverrides` entry with
    /// `package: ""` would be silently ignored by the config parser.
    #[must_use]
    pub fn with_actions(entry: MisconfiguredDependencyOverride) -> Self {
        let mut actions: Vec<IssueAction> = Vec::with_capacity(2);
        actions.push(IssueAction::Fix(FixAction {
            kind: FixActionType::FixDependencyOverride,
            auto_fixable: false,
            description:
                "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value"
                    .to_string(),
            note: Some(
                "Common shapes: bare `pkg`, scoped `@scope/pkg`, version-selector `pkg@<2`, parent-chain `parent>child`. Valid values include semver ranges, `-` (removal), `$ref` (self-ref), and `npm:alias@^1`."
                    .to_string(),
            ),
            available_in_catalogs: None,
            suggested_target: None,
        }));

        if let Some(suppress) = build_ignore_dependency_overrides_suppress(
            entry.target_package.as_deref(),
            &entry.raw_key,
            entry.source,
        ) {
            actions.push(suppress);
        }

        Self {
            entry,
            actions,
            introduced: None,
        }
    }
}

/// Shared `add-to-config` `ignoreDependencyOverrides` builder for the two
/// override findings. Returns `None` when no non-empty package name is
/// available; the config parser silently drops entries with an empty
/// `package` field, so emitting one would be a no-op that misleads agents.
fn build_ignore_dependency_overrides_suppress(
    target_package: Option<&str>,
    raw_key: &str,
    source: DependencyOverrideSource,
) -> Option<IssueAction> {
    let package = target_package
        .filter(|s| !s.is_empty())
        .or_else(|| Some(raw_key).filter(|s| !s.is_empty()))?
        .to_string();
    let mut value = serde_json::Map::new();
    value.insert("package".to_string(), serde_json::Value::String(package));
    value.insert(
        "source".to_string(),
        serde_json::Value::String(source.as_label().to_string()),
    );
    Some(IssueAction::AddToConfig(AddToConfigAction {
        kind: AddToConfigKind::AddToConfig,
        auto_fixable: false,
        description: "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).".to_string(),
        config_key: "ignoreDependencyOverrides".to_string(),
        value: AddToConfigValue::RuleObject(value),
        value_schema: Some(IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA.to_string()),
    }))
}

// ── Position-0 invariant golden tests ───────────────────────────
//
// These tests document the load-bearing position-0 semantics that flow
// downstream into the GitHub Action / GitLab CI jq scripts, the MCP server
// `actions[0].type` pattern-match, and the VS Code LSP code-action
// rendering. Snapshot tests assert structural equality; these named tests
// document WHY position 0 has a specific value, so a future refactor that
// re-orders actions tells you what broke instead of just "the snapshot
// changed".
#[cfg(test)]
mod position_0_invariants {
    use super::*;
    use crate::output::FixActionType;
    use crate::results::{DependencyOverrideSource, DuplicateLocation};
    use std::path::PathBuf;

    /// Helper: extract the kebab-case `type` discriminant from an
    /// [`IssueAction`] at a specific position. Returns `None` when the
    /// position is out of bounds or the action shape lacks a discriminant
    /// (today every variant has one).
    fn action_type(action: &IssueAction) -> &'static str {
        match action {
            IssueAction::Fix(fix) => match fix.kind {
                FixActionType::RemoveExport => "remove-export",
                FixActionType::DeleteFile => "delete-file",
                FixActionType::RemoveDependency => "remove-dependency",
                FixActionType::MoveDependency => "move-dependency",
                FixActionType::RemoveEnumMember => "remove-enum-member",
                FixActionType::RemoveClassMember => "remove-class-member",
                FixActionType::ResolveImport => "resolve-import",
                FixActionType::InstallDependency => "install-dependency",
                FixActionType::RemoveDuplicate => "remove-duplicate",
                FixActionType::MoveToDev => "move-to-dev",
                FixActionType::RefactorCycle => "refactor-cycle",
                FixActionType::RefactorReExportCycle => "refactor-re-export-cycle",
                FixActionType::RefactorBoundary => "refactor-boundary",
                FixActionType::ExportType => "export-type",
                FixActionType::RemoveCatalogEntry => "remove-catalog-entry",
                FixActionType::RemoveEmptyCatalogGroup => "remove-empty-catalog-group",
                FixActionType::UpdateCatalogReference => "update-catalog-reference",
                FixActionType::AddCatalogEntry => "add-catalog-entry",
                FixActionType::RemoveCatalogReference => "remove-catalog-reference",
                FixActionType::RemoveDependencyOverride => "remove-dependency-override",
                FixActionType::FixDependencyOverride => "fix-dependency-override",
            },
            IssueAction::SuppressLine(_) => "suppress-line",
            IssueAction::SuppressFile(_) => "suppress-file",
            IssueAction::AddToConfig(_) => "add-to-config",
        }
    }

    /// Invariant: when no other catalog declares the package, position 0
    /// of `unresolved_catalog_references[].actions` is `add-catalog-entry`,
    /// directing the agent to grow the targeted catalog.
    ///
    /// Downstream consumers (MCP `actions[0].type` dispatch, jq scripts in
    /// `action/jq/review-comments-check.jq` and `ci/jq/review-check.jq`)
    /// pattern-match on this string. A future refactor that puts the
    /// generic `remove-catalog-reference` fallback at position 0 would
    /// flip every CI annotation from "add this entry" to "remove this
    /// reference", reversing the recommended action.
    #[test]
    fn unresolved_catalog_position_0_is_add_when_no_alternatives() {
        let inner = UnresolvedCatalogReference {
            entry_name: "react".to_string(),
            catalog_name: "default".to_string(),
            path: PathBuf::from("apps/web/package.json"),
            line: 7,
            available_in_catalogs: Vec::new(),
        };
        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
        assert_eq!(
            action_type(&finding.actions[0]),
            "add-catalog-entry",
            "position-0 must be `add-catalog-entry` when no alternative catalog declares the package"
        );
        let IssueAction::Fix(fix) = &finding.actions[0] else {
            panic!("position-0 should be an IssueAction::Fix");
        };
        assert!(
            fix.available_in_catalogs.is_none(),
            "add-catalog-entry must NOT carry available_in_catalogs"
        );
        assert!(
            fix.suggested_target.is_none(),
            "add-catalog-entry must NOT carry suggested_target"
        );
    }

    /// Invariant: when at least one alternative catalog declares the
    /// package, position 0 flips to `update-catalog-reference` and carries
    /// the alternative list. When exactly one alternative exists, the
    /// action also carries `suggested_target` so deterministic agents can
    /// land the edit without picking from the list. This is the
    /// counterpart to `unresolved_catalog_position_0_is_add_when_no_alternatives`.
    #[test]
    fn unresolved_catalog_position_0_is_update_when_alternatives_exist() {
        let inner = UnresolvedCatalogReference {
            entry_name: "react".to_string(),
            catalog_name: "default".to_string(),
            path: PathBuf::from("apps/web/package.json"),
            line: 7,
            available_in_catalogs: vec!["react18".to_string()],
        };
        let finding = UnresolvedCatalogReferenceFinding::with_actions(inner);
        assert_eq!(
            action_type(&finding.actions[0]),
            "update-catalog-reference",
            "position-0 must be `update-catalog-reference` when at least one alternative catalog declares the package"
        );
        let IssueAction::Fix(fix) = &finding.actions[0] else {
            panic!("position-0 should be an IssueAction::Fix");
        };
        assert_eq!(
            fix.available_in_catalogs.as_deref(),
            Some(&["react18".to_string()][..]),
            "update-catalog-reference must carry the alternative list"
        );
        assert_eq!(
            fix.suggested_target.as_deref(),
            Some("react18"),
            "single-alternative case must surface `suggested_target` for deterministic agents"
        );

        // Two alternatives: still update, but no unambiguous target.
        let inner_two = UnresolvedCatalogReference {
            entry_name: "react".to_string(),
            catalog_name: "default".to_string(),
            path: PathBuf::from("apps/web/package.json"),
            line: 7,
            available_in_catalogs: vec!["react17".to_string(), "react18".to_string()],
        };
        let finding_two = UnresolvedCatalogReferenceFinding::with_actions(inner_two);
        assert_eq!(
            action_type(&finding_two.actions[0]),
            "update-catalog-reference"
        );
        let IssueAction::Fix(fix_two) = &finding_two.actions[0] else {
            panic!("position-0 should be an IssueAction::Fix");
        };
        assert!(
            fix_two.suggested_target.is_none(),
            "multi-alternative case must NOT carry `suggested_target` (agent must pick)"
        );
    }

    /// Invariant: position 0 of `duplicate_exports[].actions` is
    /// `add-to-config` (the safe `ignoreExports` rule for the
    /// namespace-barrel case), NOT the destructive `remove-duplicate`.
    ///
    /// This protects the shadcn / Radix / bits-ui pattern where every
    /// `components/ui/<name>/index.ts` intentionally re-exports the same
    /// short names. Any consumer that reads `actions[0].type` as "the
    /// recommended fix" must see the non-destructive path first; flipping
    /// position 0 to `remove-duplicate` would propose deleting an
    /// intentional API surface.
    ///
    /// This test pins position 0 across both possible auto_fixable values
    /// for the add-to-config action (the per-instance flip flag handled
    /// by `set_config_fixable`).
    #[test]
    fn duplicate_exports_position_0_is_add_to_config_not_remove_duplicate() {
        let inner = DuplicateExport {
            export_name: "Root".to_string(),
            locations: vec![
                DuplicateLocation {
                    path: PathBuf::from("components/ui/accordion/index.ts"),
                    line: 1,
                    col: 0,
                },
                DuplicateLocation {
                    path: PathBuf::from("components/ui/dialog/index.ts"),
                    line: 1,
                    col: 0,
                },
            ],
        };
        let finding = DuplicateExportFinding::with_actions(inner);
        assert_eq!(
            action_type(&finding.actions[0]),
            "add-to-config",
            "position-0 must be `add-to-config` (safe `ignoreExports` path), NOT `remove-duplicate`"
        );
        assert_eq!(
            action_type(&finding.actions[1]),
            "remove-duplicate",
            "position-1 must be the destructive `remove-duplicate` fallback"
        );

        // `set_config_fixable(true)` flips the position-0 add-to-config
        // bool but must NOT re-order positions.
        let mut promoted = finding;
        promoted.set_config_fixable(true);
        assert_eq!(action_type(&promoted.actions[0]), "add-to-config");
        let IssueAction::AddToConfig(action) = &promoted.actions[0] else {
            panic!("position-0 should still be AddToConfig after set_config_fixable");
        };
        assert!(
            action.auto_fixable,
            "set_config_fixable(true) must flip auto_fixable"
        );
    }

    /// Invariant: a duplicate-exports finding with empty `locations`
    /// degenerate input drops the `add-to-config` action entirely, so
    /// position 0 falls through to `remove-duplicate`. Documents the
    /// degenerate-case contract.
    #[test]
    fn duplicate_exports_no_locations_falls_through_to_remove_duplicate() {
        let inner = DuplicateExport {
            export_name: "Root".to_string(),
            locations: Vec::new(),
        };
        let finding = DuplicateExportFinding::with_actions(inner);
        assert_eq!(
            action_type(&finding.actions[0]),
            "remove-duplicate",
            "with no locations there is no ignoreExports rule to suggest; the destructive remove becomes position-0"
        );

        // `set_config_fixable(true)` is a no-op on this shape.
        let mut promoted = finding;
        promoted.set_config_fixable(true);
        assert_eq!(
            action_type(&promoted.actions[0]),
            "remove-duplicate",
            "set_config_fixable is a no-op when position-0 is not add-to-config"
        );
    }

    /// Invariant: misconfigured-dependency-override with empty
    /// `target_package` AND empty `raw_key` drops the suppress action
    /// (no usable package name for the `ignoreDependencyOverrides`
    /// matcher; emitting `package: ""` would be silently dropped by the
    /// config parser). Documents the suppress-omission contract.
    #[test]
    fn misconfigured_override_drops_suppress_when_no_package_name() {
        let inner = MisconfiguredDependencyOverride {
            raw_key: String::new(),
            target_package: None,
            raw_value: String::new(),
            reason: crate::results::DependencyOverrideMisconfigReason::EmptyValue,
            source: DependencyOverrideSource::PnpmWorkspaceYaml,
            path: PathBuf::from("pnpm-workspace.yaml"),
            line: 12,
        };
        let finding = MisconfiguredDependencyOverrideFinding::with_actions(inner);
        // Only the primary fix-dependency-override action: no suppress.
        assert_eq!(finding.actions.len(), 1);
        assert_eq!(action_type(&finding.actions[0]), "fix-dependency-override");
    }
}
