use crate::domain::{LanguageId, LanguageStatus, StaticLimitKind};
use crate::output::first_useful_action::DEFAULT_FIRST_USEFUL_ACTION_OUT;
use crate::output::gap_decision_ledger::DEFAULT_GAP_DECISION_LEDGER_OUT;
use serde_json::Value;
use std::collections::BTreeSet;
use std::io::ErrorKind;
use std::path::{Component, Path};

const DEFAULT_ACTIONABLE_GAPS_OUT: &str = "target/ripr/reports/actionable-gaps.json";

#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct GapArtifactValidationContext<'a> {
    pub(super) root: &'a Path,
    pub(super) enabled_languages: &'a [LanguageId],
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct ValidatedGapArtifact {
    pub(super) kind: GapArtifactKind,
    pub(super) root: Option<String>,
    pub(super) identities: Vec<GapArtifactIdentity>,
    pub(super) language: Option<LanguageId>,
    pub(super) language_status: Option<LanguageStatus>,
    pub(super) gap_state: Option<String>,
    pub(super) related_paths: Vec<String>,
    pub(super) verify_commands: Vec<String>,
    pub(super) receipt_commands: Vec<String>,
    pub(super) static_limit_kinds: Vec<String>,
    pub(super) has_text_static_limit: bool,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct GapArtifactValidationReport {
    pub(super) artifacts: Vec<ValidatedGapArtifact>,
    pub(super) rejections: Vec<GapArtifactRejection>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum GapArtifactKind {
    ActionableGaps,
    EvidenceRecord,
    FirstUsefulAction,
    GapDecisionLedger,
    AgentReceipt,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct GapArtifactIdentity {
    pub(super) canonical_gap_id: Option<String>,
    pub(super) seam_id: Option<String>,
    pub(super) finding_id: Option<String>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum GapArtifactRejection {
    DisabledLanguage(String),
    MalformedArtifact(&'static str),
    MalformedCommandPayload(String),
    MissingIdentity,
    OutOfWorkspacePath(String),
    StaleArtifact,
    UnavailableLanguage(String),
    UnsupportedSchema(String),
    UnsupportedStaticLimitKind(String),
    UnsupportedKind(String),
    WrongRoot(String),
}

impl ValidatedGapArtifact {
    pub(super) fn is_safe_projection_input(&self) -> bool {
        let kind_is_supported = matches!(
            self.kind,
            GapArtifactKind::ActionableGaps
                | GapArtifactKind::EvidenceRecord
                | GapArtifactKind::FirstUsefulAction
                | GapArtifactKind::GapDecisionLedger
                | GapArtifactKind::AgentReceipt
        );
        let root_is_present_or_deferred = self
            .root
            .as_ref()
            .is_none_or(|root| !root.trim().is_empty());
        let identity_is_present = self.identities.iter().any(|identity| {
            identity.canonical_gap_id.is_some()
                || identity.seam_id.is_some()
                || identity.finding_id.is_some()
        }) || (self.kind == GapArtifactKind::ActionableGaps
            && self.is_no_action_gap());
        let language_is_supported = self.language.is_none_or(|language| language.is_available());
        let status_is_supported = self.language_status.is_none_or(|status| {
            matches!(status, LanguageStatus::Stable | LanguageStatus::Preview)
        });
        let gap_state_is_present_or_deferred = self
            .gap_state
            .as_ref()
            .is_none_or(|state| !state.trim().is_empty());
        let path_payloads_are_present = self
            .related_paths
            .iter()
            .all(|path| !path.trim().is_empty());
        let command_payloads_are_present = self
            .verify_commands
            .iter()
            .chain(self.receipt_commands.iter())
            .all(|command| !command.trim().is_empty());
        let static_limits_are_structured_or_text = self
            .static_limit_kinds
            .iter()
            .all(|kind| known_static_limit_kind(kind))
            || self.has_text_static_limit;

        kind_is_supported
            && root_is_present_or_deferred
            && identity_is_present
            && language_is_supported
            && status_is_supported
            && gap_state_is_present_or_deferred
            && path_payloads_are_present
            && command_payloads_are_present
            && static_limits_are_structured_or_text
    }

    pub(super) fn is_actionable_gap(&self) -> bool {
        self.gap_state.as_deref() == Some("actionable")
    }

    pub(super) fn is_no_action_gap(&self) -> bool {
        matches!(
            self.gap_state.as_deref(),
            Some(
                "already_improved"
                    | "baseline_only"
                    | "no_actionable_seam"
                    | "suppressed"
                    | "acknowledged"
                    | "waived"
            )
        )
    }

    pub(super) fn is_preview(&self) -> bool {
        self.language_status == Some(LanguageStatus::Preview)
    }

    pub(super) fn has_static_limit(&self) -> bool {
        !self.static_limit_kinds.is_empty() || self.has_text_static_limit
    }
}

impl GapArtifactRejection {
    pub(super) fn as_str(&self) -> &'static str {
        match self {
            Self::DisabledLanguage(_) => "disabled_language",
            Self::MalformedArtifact(_) => "malformed_artifact",
            Self::MalformedCommandPayload(_) => "malformed_command_payload",
            Self::MissingIdentity => "missing_identity",
            Self::OutOfWorkspacePath(_) => "out_of_workspace_path",
            Self::StaleArtifact => "stale_artifact",
            Self::UnavailableLanguage(_) => "unavailable_language",
            Self::UnsupportedSchema(_) => "unsupported_schema",
            Self::UnsupportedStaticLimitKind(_) => "unsupported_static_limit_kind",
            Self::UnsupportedKind(_) => "unsupported_kind",
            Self::WrongRoot(_) => "wrong_root",
        }
    }
}

pub(super) fn validate_workspace_gap_artifact_report(
    root: &Path,
    enabled_languages: &[LanguageId],
) -> GapArtifactValidationReport {
    let context = GapArtifactValidationContext {
        root,
        enabled_languages,
    };
    let mut report = GapArtifactValidationReport::default();
    for relative in [
        DEFAULT_ACTIONABLE_GAPS_OUT,
        DEFAULT_FIRST_USEFUL_ACTION_OUT,
        DEFAULT_GAP_DECISION_LEDGER_OUT,
    ] {
        match validate_workspace_gap_artifact(root, relative, &context) {
            Some(Ok(artifact)) => report.artifacts.push(artifact),
            Some(Err(rejection)) => report.rejections.push(rejection),
            None => {}
        }
    }
    report
}

fn validate_workspace_gap_artifact(
    root: &Path,
    relative: &str,
    context: &GapArtifactValidationContext<'_>,
) -> Option<Result<ValidatedGapArtifact, GapArtifactRejection>> {
    let text = match std::fs::read_to_string(root.join(relative)) {
        Ok(text) => text,
        Err(err) if err.kind() == ErrorKind::NotFound => return None,
        Err(_) => {
            return Some(Err(GapArtifactRejection::MalformedArtifact(
                "gap artifact file must be readable",
            )));
        }
    };
    let value = match serde_json::from_str::<Value>(&text) {
        Ok(value) => value,
        Err(_) => {
            return Some(Err(GapArtifactRejection::MalformedArtifact(
                "gap artifact JSON must parse",
            )));
        }
    };
    Some(validate_gap_artifact(&value, context))
}

pub(super) fn validate_gap_artifact(
    artifact: &Value,
    context: &GapArtifactValidationContext<'_>,
) -> Result<ValidatedGapArtifact, GapArtifactRejection> {
    let object = artifact
        .as_object()
        .ok_or(GapArtifactRejection::MalformedArtifact(
            "gap artifact root must be a JSON object",
        ))?;
    validate_schema(object.get("schema_version").and_then(Value::as_str))?;
    let kind = artifact_kind(artifact)?;
    let report_root = object.get("root").and_then(Value::as_str);
    validate_report_root(context.root, report_root)?;
    validate_freshness(artifact)?;

    let mut validation = match kind {
        GapArtifactKind::ActionableGaps => {
            validate_actionable_gaps(artifact, report_root, context)?
        }
        GapArtifactKind::FirstUsefulAction => validate_first_useful_action(artifact, report_root)?,
        GapArtifactKind::GapDecisionLedger => validate_gap_decision_ledger(artifact, report_root)?,
        GapArtifactKind::EvidenceRecord => validate_evidence_record(artifact, report_root)?,
        GapArtifactKind::AgentReceipt => validate_agent_receipt(artifact, report_root)?,
    };

    validate_language(context, validation.language, validation.language_status)?;
    validate_paths(context.root, &validation.related_paths)?;
    validate_commands(context.root, &validation.verify_commands)?;
    validate_commands(context.root, &validation.receipt_commands)?;
    validate_static_limits(artifact)?;
    normalize_static_limits(artifact, &mut validation);
    if validation.identities.is_empty()
        && !(validation.kind == GapArtifactKind::ActionableGaps && validation.is_no_action_gap())
    {
        return Err(GapArtifactRejection::MissingIdentity);
    }
    Ok(validation)
}

fn validate_first_useful_action(
    artifact: &Value,
    report_root: Option<&str>,
) -> Result<ValidatedGapArtifact, GapArtifactRejection> {
    let selected = artifact.get("selected");
    let target = artifact.get("target");
    let commands = artifact.get("commands");
    let identity = identity_from_sources(&[selected, Some(artifact)])
        .ok_or(GapArtifactRejection::MissingIdentity)?;
    let related_paths = string_values(&[
        path_value(target, &["file"]),
        path_value(target, &["related_test"]),
        path_value(selected, &["path"]),
    ]);
    let verify_commands = string_values(&[path_value(commands, &["verify"])]);
    let receipt_commands = string_values(&[path_value(commands, &["receipt"])]);
    Ok(ValidatedGapArtifact {
        kind: GapArtifactKind::FirstUsefulAction,
        root: report_root.map(ToOwned::to_owned),
        identities: vec![identity],
        language: language_from_value(artifact)?,
        language_status: language_status_from_value(artifact)?,
        gap_state: artifact
            .get("status")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
        related_paths,
        verify_commands,
        receipt_commands,
        static_limit_kinds: Vec::new(),
        has_text_static_limit: false,
    })
}

fn validate_gap_decision_ledger(
    artifact: &Value,
    report_root: Option<&str>,
) -> Result<ValidatedGapArtifact, GapArtifactRejection> {
    let records = artifact
        .get("records")
        .or_else(|| artifact.get("gap_records"))
        .and_then(Value::as_array)
        .ok_or(GapArtifactRejection::MalformedArtifact(
            "gap decision ledger must contain records",
        ))?;
    let mut identities = Vec::new();
    let mut related_paths = Vec::new();
    let mut verify_commands = Vec::new();
    let mut receipt_commands = Vec::new();
    let mut language = None;
    let mut language_status = None;
    let gap_state;
    let mut has_actionable_record = false;
    let mut no_action_records = 0usize;
    for record in records {
        identities.push(
            identity_from_sources(&[Some(record)]).ok_or(GapArtifactRejection::MissingIdentity)?,
        );
        let record_language = language_from_value(record)?;
        let record_language_status = language_status_from_value(record)?;
        if record_language_status == Some(LanguageStatus::Preview) {
            language = record_language.or(language);
            language_status = Some(LanguageStatus::Preview);
        } else {
            language = language.or(record_language);
            language_status = language_status.or(record_language_status);
        }
        if record_is_actionable_for_editor(record) {
            has_actionable_record = true;
        }
        if record_is_no_action(record) {
            no_action_records += 1;
        }
        let route = record.get("repair_route");
        let anchor = record.get("anchor");
        let receipt = record.get("receipt");
        related_paths.extend(string_values(&[
            path_value(route, &["target_file"]),
            path_value(route, &["related_test"]),
            path_value(anchor, &["file"]),
            path_value(receipt, &["path"]),
        ]));
        if let Some(commands) = record
            .get("verification_commands")
            .and_then(Value::as_array)
        {
            verify_commands.extend(
                commands
                    .iter()
                    .filter_map(Value::as_str)
                    .map(ToOwned::to_owned),
            );
        }
        if let Some(command) = record.get("receipt_command").and_then(Value::as_str) {
            receipt_commands.push(command.to_string());
        }
    }
    if has_actionable_record {
        gap_state = Some("actionable".to_string());
    } else if !records.is_empty() && no_action_records == records.len() {
        gap_state = Some("no_actionable_seam".to_string());
    } else {
        gap_state = records.iter().find_map(|record| {
            record
                .get("gap_state")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned)
        });
    }
    Ok(ValidatedGapArtifact {
        kind: GapArtifactKind::GapDecisionLedger,
        root: report_root.map(ToOwned::to_owned),
        identities,
        language,
        language_status,
        gap_state,
        related_paths,
        verify_commands,
        receipt_commands,
        static_limit_kinds: Vec::new(),
        has_text_static_limit: false,
    })
}

fn validate_actionable_gaps(
    artifact: &Value,
    report_root: Option<&str>,
    context: &GapArtifactValidationContext<'_>,
) -> Result<ValidatedGapArtifact, GapArtifactRejection> {
    if report_has_run_limitations(artifact) {
        return Err(GapArtifactRejection::MalformedArtifact(
            "actionable-gaps report must not carry run_limitations for editor projection",
        ));
    }
    let packets = artifact.get("packets").and_then(Value::as_array).ok_or(
        GapArtifactRejection::MalformedArtifact("actionable-gaps report must contain packets"),
    )?;
    let mut identities = Vec::new();
    let mut related_paths = Vec::new();
    let mut verify_commands = Vec::new();
    let mut receipt_commands = Vec::new();
    let mut language = None;
    let mut language_status = None;
    let mut has_actionable_packet = false;
    let mut no_action_packets = 0usize;

    for packet in packets {
        let packet_language = language_from_actionable_packet(packet)?;
        let packet_language_status = language_status_from_actionable_packet(packet)?;
        validate_actionable_packet_languages(context, packet)?;
        if packet_language_status == Some(LanguageStatus::Preview) {
            language = packet_language.or(language);
            language_status = Some(LanguageStatus::Preview);
        } else {
            language = language.or(packet_language);
            language_status = language_status.or(packet_language_status);
        }

        if packet_is_actionable(packet) {
            has_actionable_packet = true;
            identities.push(
                identity_from_sources(&[Some(packet)])
                    .ok_or(GapArtifactRejection::MissingIdentity)?,
            );
            validate_actionable_packet_projection_fields(packet)?;
            require_actionable_packet_string(
                packet,
                &["repair_kind"],
                "actionable packet must carry repair_kind",
            )?;
            require_actionable_packet_string(
                packet,
                &["target_test_type"],
                "actionable packet must carry target_test_type",
            )?;
            require_actionable_packet_string(
                packet,
                &["assertion_shape"],
                "actionable packet must carry assertion_shape",
            )?;
            require_actionable_packet_string(
                packet,
                &["verify_command"],
                "actionable packet must carry verify_command",
            )?;
            require_actionable_packet_string(
                packet,
                &["confidence_basis"],
                "actionable packet must carry confidence_basis",
            )?;
            require_actionable_packet_string(
                packet,
                &["receipt_command_or_path"],
                "actionable packet must carry receipt_command_or_path",
            )?;
        } else {
            if packet_is_no_action(packet) {
                no_action_packets += 1;
            }
            if let Some(identity) = identity_from_sources(&[Some(packet)]) {
                identities.push(identity);
            }
        }

        related_paths.extend(actionable_packet_related_paths(packet));
        if let Some(command) = path_value(Some(packet), &["verify_command"]).and_then(Value::as_str)
        {
            verify_commands.push(command.to_string());
        }
        if let Some(receipt) = path_value(Some(packet), &["receipt_command_or_path"])
            .and_then(Value::as_str)
            .and_then(non_empty)
        {
            if looks_like_command_payload(&receipt) {
                receipt_commands.push(receipt);
            } else {
                related_paths.push(receipt);
            }
        }
    }

    let gap_state = if has_actionable_packet {
        Some("actionable".to_string())
    } else if packets.is_empty() {
        if !actionable_gaps_empty_queue_is_complete(artifact) {
            return Err(GapArtifactRejection::MalformedArtifact(
                "empty actionable-gaps report must carry completed zero-count summary",
            ));
        }
        Some("no_actionable_seam".to_string())
    } else if no_action_packets == packets.len() {
        Some("no_actionable_seam".to_string())
    } else {
        packets.iter().find_map(|packet| {
            packet
                .get("gap_state")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned)
        })
    };

    Ok(ValidatedGapArtifact {
        kind: GapArtifactKind::ActionableGaps,
        root: report_root.map(ToOwned::to_owned),
        identities,
        language,
        language_status,
        gap_state,
        related_paths,
        verify_commands,
        receipt_commands,
        static_limit_kinds: Vec::new(),
        has_text_static_limit: false,
    })
}

fn report_has_run_limitations(artifact: &Value) -> bool {
    artifact
        .get("run_limitations")
        .and_then(Value::as_array)
        .is_some_and(|items| !items.is_empty())
}

fn actionable_gaps_empty_queue_is_complete(artifact: &Value) -> bool {
    let Some(summary) = artifact.get("summary") else {
        return false;
    };
    numeric_value(summary, &["actionable_gaps"]) == Some(0)
        && numeric_value(summary, &["packets_emitted"]) == Some(0)
}

fn numeric_value(value: &Value, path: &[&str]) -> Option<u64> {
    path_value(Some(value), path).and_then(Value::as_u64)
}

fn validate_actionable_packet_projection_fields(
    packet: &Value,
) -> Result<(), GapArtifactRejection> {
    require_actionable_packet_source(
        packet,
        &["repair_route_source"],
        "canonical_item.repair_route",
        "actionable packet must carry canonical repair_route_source",
    )?;
    require_actionable_packet_source(
        packet,
        &["verify_command_source"],
        "canonical_item.verify_command",
        "actionable packet must carry canonical verify_command_source",
    )?;
    if packet
        .get("public_projection_eligible")
        .and_then(Value::as_bool)
        != Some(true)
    {
        return Err(GapArtifactRejection::MalformedArtifact(
            "actionable packet must be public_projection_eligible",
        ));
    }
    if packet
        .get("projection_exclusion_reasons")
        .and_then(Value::as_array)
        .is_none_or(|reasons| !reasons.is_empty())
    {
        return Err(GapArtifactRejection::MalformedArtifact(
            "actionable packet must not carry projection_exclusion_reasons",
        ));
    }
    Ok(())
}

fn require_actionable_packet_source(
    packet: &Value,
    path: &[&str],
    expected: &str,
    message: &'static str,
) -> Result<(), GapArtifactRejection> {
    if path_value(Some(packet), path).and_then(Value::as_str) == Some(expected) {
        Ok(())
    } else {
        Err(GapArtifactRejection::MalformedArtifact(message))
    }
}

fn validate_actionable_packet_languages(
    context: &GapArtifactValidationContext<'_>,
    packet: &Value,
) -> Result<(), GapArtifactRejection> {
    validate_language(
        context,
        language_from_value(packet)?,
        language_status_from_value(packet)?,
    )?;
    if let Some(findings) = packet.get("raw_findings").and_then(Value::as_array) {
        for finding in findings {
            validate_language(
                context,
                language_from_value(finding)?,
                language_status_from_value(finding)?,
            )?;
        }
    }
    Ok(())
}

fn record_is_actionable_for_editor(record: &Value) -> bool {
    record.get("gap_state").and_then(Value::as_str) == Some("actionable")
        && record.get("repairability").and_then(Value::as_str) == Some("repairable")
        && path_value(
            Some(record),
            &["projection_eligibility", "lsp_diagnostic", "eligible"],
        )
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

fn record_is_no_action(record: &Value) -> bool {
    record.get("repairability").and_then(Value::as_str) == Some("no_action")
        || matches!(
            record.get("gap_state").and_then(Value::as_str),
            Some(
                "already_improved"
                    | "already_observed"
                    | "baseline_only"
                    | "internal"
                    | "internal_only"
                    | "no_actionable_seam"
                    | "not_policy_targeted"
                    | "report_only"
                    | "resolved"
                    | "suppressed"
                    | "acknowledged"
                    | "waived"
            )
        )
}

fn packet_is_actionable(packet: &Value) -> bool {
    packet.get("gap_state").and_then(Value::as_str) == Some("actionable")
}

fn packet_is_no_action(packet: &Value) -> bool {
    matches!(
        packet.get("gap_state").and_then(Value::as_str),
        Some(
            "already_improved"
                | "already_observed"
                | "baseline_only"
                | "internal"
                | "internal_only"
                | "no_actionable_seam"
                | "not_policy_targeted"
                | "report_only"
                | "resolved"
                | "static_limit_only"
                | "suppressed"
                | "acknowledged"
                | "waived"
        )
    ) || matches!(
        packet.get("actionability").and_then(Value::as_str),
        Some("no_action" | "report_only" | "static_limit_only")
    )
}

fn require_actionable_packet_string(
    packet: &Value,
    path: &[&str],
    message: &'static str,
) -> Result<(), GapArtifactRejection> {
    let value = path_value(Some(packet), path)
        .and_then(Value::as_str)
        .and_then(non_empty)
        .filter(|value| !actionable_packet_guidance_is_missing(value))
        .ok_or(GapArtifactRejection::MalformedArtifact(message))?;
    if value.contains('\n') || value.contains('\r') {
        return Err(GapArtifactRejection::MalformedArtifact(message));
    }
    Ok(())
}

fn actionable_packet_guidance_is_missing(value: &str) -> bool {
    matches!(
        value.trim(),
        "repair_route_unknown"
            | "repair_kind_unknown"
            | "verify_command_unknown"
            | "target_test_type_unknown"
            | "assertion_shape_unknown"
            | "recommended_repair_unknown"
    )
}

fn actionable_packet_related_paths(packet: &Value) -> Vec<String> {
    let observer = packet.get("related_test_or_observer");
    let primary_anchor = packet.get("primary_anchor");
    string_values(&[
        path_value(Some(packet), &["source_file"]),
        path_value(Some(packet), &["target_test"]),
        path_value(Some(packet), &["target_file"]),
        path_value(primary_anchor, &["file"]),
        path_value(observer, &["file"]),
        path_value(observer, &["path"]),
        path_value(observer, &["related_test"]),
        path_value(observer, &["test"]),
        path_value(observer, &["target_file"]),
    ])
}

fn validate_evidence_record(
    artifact: &Value,
    report_root: Option<&str>,
) -> Result<ValidatedGapArtifact, GapArtifactRejection> {
    let identity = identity_from_sources(&[
        Some(artifact),
        artifact.get("canonical_item"),
        artifact.get("identity"),
    ])
    .ok_or(GapArtifactRejection::MissingIdentity)?;
    let location = artifact.get("location");
    let canonical_item = artifact.get("canonical_item");
    let recommendation = artifact.get("recommendation");
    let related_paths = string_values(&[
        path_value(location, &["file"]),
        path_value(canonical_item, &["related_test", "file"]),
        path_value(recommendation, &["recommended_test", "file"]),
        path_value(recommendation, &["nearest_test_to_imitate", "file"]),
    ]);
    let verify_commands = string_values(&[
        path_value(canonical_item, &["verify_command"]),
        path_value(recommendation, &["verify_command"]),
    ]);
    Ok(ValidatedGapArtifact {
        kind: GapArtifactKind::EvidenceRecord,
        root: report_root.map(ToOwned::to_owned),
        identities: vec![identity],
        language: language_from_value(artifact)?,
        language_status: language_status_from_value(artifact)?,
        gap_state: path_value(canonical_item, &["gap_state"])
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
        related_paths,
        verify_commands,
        receipt_commands: Vec::new(),
        static_limit_kinds: Vec::new(),
        has_text_static_limit: false,
    })
}

fn validate_agent_receipt(
    artifact: &Value,
    report_root: Option<&str>,
) -> Result<ValidatedGapArtifact, GapArtifactRejection> {
    let identity = identity_from_sources(&[Some(artifact), artifact.get("selected")])
        .ok_or(GapArtifactRejection::MissingIdentity)?;
    let inputs = artifact.get("inputs");
    let related_paths = string_values(&[
        path_value(inputs, &["receipt"]),
        path_value(inputs, &["verify"]),
        path_value(artifact.get("target"), &["file"]),
    ]);
    Ok(ValidatedGapArtifact {
        kind: GapArtifactKind::AgentReceipt,
        root: report_root.map(ToOwned::to_owned),
        identities: vec![identity],
        language: language_from_value(artifact)?,
        language_status: language_status_from_value(artifact)?,
        gap_state: artifact
            .get("movement")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
        related_paths,
        verify_commands: Vec::new(),
        receipt_commands: Vec::new(),
        static_limit_kinds: Vec::new(),
        has_text_static_limit: false,
    })
}

fn validate_schema(schema_version: Option<&str>) -> Result<(), GapArtifactRejection> {
    match schema_version {
        Some("0.1") => Ok(()),
        Some(version) => Err(GapArtifactRejection::UnsupportedSchema(version.to_string())),
        None => Err(GapArtifactRejection::UnsupportedSchema(
            "missing".to_string(),
        )),
    }
}

fn artifact_kind(artifact: &Value) -> Result<GapArtifactKind, GapArtifactRejection> {
    let kind = artifact.get("kind").and_then(Value::as_str);
    match kind {
        Some("first_useful_action") => Ok(GapArtifactKind::FirstUsefulAction),
        Some("gap_decision_ledger") => Ok(GapArtifactKind::GapDecisionLedger),
        Some("agent_receipt") => Ok(GapArtifactKind::AgentReceipt),
        Some(other) => Err(GapArtifactRejection::UnsupportedKind(other.to_string())),
        None if artifact.get("report").and_then(Value::as_str) == Some("actionable-gaps") => {
            Ok(GapArtifactKind::ActionableGaps)
        }
        None if artifact.get("evidence_path").is_some() && artifact.get("seam_id").is_some() => {
            Ok(GapArtifactKind::EvidenceRecord)
        }
        None => Err(GapArtifactRejection::UnsupportedKind("missing".to_string())),
    }
}

fn validate_report_root(
    root: &Path,
    report_root: Option<&str>,
) -> Result<(), GapArtifactRejection> {
    let Some(report_root) = report_root else {
        return Ok(());
    };
    if report_root == "." || normalize_path(root) == normalize_path(&root.join(report_root)) {
        return Ok(());
    }
    let candidate = Path::new(report_root);
    if candidate.is_absolute() && normalize_path(candidate) == normalize_path(root) {
        return Ok(());
    }
    Err(GapArtifactRejection::WrongRoot(report_root.to_string()))
}

fn validate_freshness(artifact: &Value) -> Result<(), GapArtifactRejection> {
    for path in [
        &["status"][..],
        &["freshness"][..],
        &["editor_context", "freshness"][..],
    ] {
        if path_value(Some(artifact), path).and_then(Value::as_str) == Some("stale") {
            return Err(GapArtifactRejection::StaleArtifact);
        }
    }
    Ok(())
}

fn validate_language(
    context: &GapArtifactValidationContext<'_>,
    language: Option<LanguageId>,
    language_status: Option<LanguageStatus>,
) -> Result<(), GapArtifactRejection> {
    let Some(language) = language else {
        return Ok(());
    };
    if !language.is_available() {
        return Err(GapArtifactRejection::UnavailableLanguage(
            language.as_str().to_string(),
        ));
    }
    if !context.enabled_languages.contains(&language) {
        return Err(GapArtifactRejection::DisabledLanguage(
            language.as_str().to_string(),
        ));
    }
    if language_status == Some(LanguageStatus::Preview) && language == LanguageId::Rust {
        return Err(GapArtifactRejection::MalformedArtifact(
            "rust gap artifacts must not be labeled preview",
        ));
    }
    Ok(())
}

fn validate_paths(root: &Path, paths: &[String]) -> Result<(), GapArtifactRejection> {
    for path in paths {
        if !workspace_path_is_safe(root, path) {
            return Err(GapArtifactRejection::OutOfWorkspacePath(path.clone()));
        }
    }
    Ok(())
}

fn validate_commands(root: &Path, commands: &[String]) -> Result<(), GapArtifactRejection> {
    for command in commands {
        if !command_payload_is_safe(root, command) {
            return Err(GapArtifactRejection::MalformedCommandPayload(
                command.clone(),
            ));
        }
    }
    Ok(())
}

fn validate_static_limits(artifact: &Value) -> Result<(), GapArtifactRejection> {
    for kind in static_limit_kind_values(artifact) {
        if !known_static_limit_kind(&kind) {
            return Err(GapArtifactRejection::UnsupportedStaticLimitKind(kind));
        }
    }
    Ok(())
}

fn normalize_static_limits(artifact: &Value, validation: &mut ValidatedGapArtifact) {
    let mut seen = BTreeSet::new();
    for kind in static_limit_kind_values(artifact) {
        if seen.insert(kind.clone()) {
            validation.static_limit_kinds.push(kind);
        }
    }
    validation.has_text_static_limit = contains_text_static_limit(artifact);
}

fn identity_from_sources(sources: &[Option<&Value>]) -> Option<GapArtifactIdentity> {
    let canonical_gap_id = string_from_sources(sources, &["canonical_gap_id"])
        .or_else(|| string_from_sources(sources, &["identity", "canonical_gap_id"]))
        .or_else(|| string_from_sources(sources, &["evidence_record", "canonical_gap_id"]));
    let seam_id = string_from_sources(sources, &["seam_id"]);
    let finding_id = string_from_sources(sources, &["finding_id"]);
    if canonical_gap_id.is_none() && seam_id.is_none() && finding_id.is_none() {
        return None;
    }
    Some(GapArtifactIdentity {
        canonical_gap_id,
        seam_id,
        finding_id,
    })
}

fn language_from_value(value: &Value) -> Result<Option<LanguageId>, GapArtifactRejection> {
    let language = value.get("language").and_then(Value::as_str);
    match language {
        None => Ok(None),
        Some("rust") => Ok(Some(LanguageId::Rust)),
        Some("typescript") | Some("javascript") => Ok(Some(LanguageId::TypeScript)),
        Some("python") => Ok(Some(LanguageId::Python)),
        Some(other) => Err(GapArtifactRejection::MalformedArtifact(match other {
            "" => "language must not be empty",
            _ => "unsupported language",
        })),
    }
}

fn language_from_actionable_packet(
    packet: &Value,
) -> Result<Option<LanguageId>, GapArtifactRejection> {
    if let Some(language) = language_from_value(packet)? {
        return Ok(Some(language));
    }
    packet
        .get("raw_findings")
        .and_then(Value::as_array)
        .and_then(|findings| {
            findings
                .iter()
                .find_map(|finding| language_from_value(finding).transpose())
        })
        .transpose()
}

fn language_status_from_value(
    value: &Value,
) -> Result<Option<LanguageStatus>, GapArtifactRejection> {
    match value.get("language_status").and_then(Value::as_str) {
        None => Ok(None),
        Some("stable") => Ok(Some(LanguageStatus::Stable)),
        Some("preview") => Ok(Some(LanguageStatus::Preview)),
        Some(_) => Err(GapArtifactRejection::MalformedArtifact(
            "unsupported language_status",
        )),
    }
}

fn language_status_from_actionable_packet(
    packet: &Value,
) -> Result<Option<LanguageStatus>, GapArtifactRejection> {
    if let Some(status) = language_status_from_value(packet)? {
        return Ok(Some(status));
    }
    packet
        .get("raw_findings")
        .and_then(Value::as_array)
        .and_then(|findings| {
            findings
                .iter()
                .find_map(|finding| language_status_from_value(finding).transpose())
        })
        .transpose()
}

fn string_values(values: &[Option<&Value>]) -> Vec<String> {
    values
        .iter()
        .filter_map(|value| value.and_then(Value::as_str))
        .filter_map(non_empty)
        .collect()
}

fn string_from_sources(sources: &[Option<&Value>], path: &[&str]) -> Option<String> {
    sources
        .iter()
        .find_map(|source| path_value(*source, path).and_then(Value::as_str))
        .and_then(non_empty)
}

fn non_empty(value: &str) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

fn path_value<'a>(value: Option<&'a Value>, path: &[&str]) -> Option<&'a Value> {
    let mut current = value?;
    for key in path {
        current = current.get(*key)?;
    }
    Some(current)
}

pub(super) fn workspace_path_is_safe(root: &Path, raw: &str) -> bool {
    let path_text = path_part(raw).trim();
    if path_text.is_empty() || path_text.contains('\n') || path_text.contains('\r') {
        return false;
    }
    let path = Path::new(path_text);
    if path.is_absolute() {
        return is_inside_workspace(root, path);
    }
    !path.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    })
}

fn path_part(raw: &str) -> &str {
    raw.split_once("::").map_or(raw, |(path, _)| path)
}

pub(super) fn command_payload_is_safe(root: &Path, command: &str) -> bool {
    let trimmed = command.trim();
    if trimmed.is_empty() || trimmed.contains('\n') || trimmed.contains('\r') {
        return false;
    }
    if trimmed
        .chars()
        .any(|character| matches!(character, ';' | '&' | '|' | '<' | '>' | '`'))
    {
        return false;
    }
    if trimmed.contains("../") || trimmed.contains("..\\") {
        return false;
    }
    let tokens = command_tokens(trimmed);
    if !matches!(tokens.first().map(String::as_str), Some("cargo" | "ripr")) {
        return false;
    }
    for index in 0..tokens.len() {
        if tokens[index] == "--root" {
            let Some(root_arg) = tokens.get(index + 1) else {
                return false;
            };
            if root_arg != "."
                && normalize_path(Path::new(root_arg)) != normalize_path(root)
                && !workspace_path_is_safe(root, root_arg)
            {
                return false;
            }
        }
    }
    true
}

fn looks_like_command_payload(value: &str) -> bool {
    let trimmed = value.trim_start();
    trimmed.starts_with("cargo ") || trimmed.starts_with("ripr ")
}

fn command_tokens(command: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut current = String::new();
    let mut quote = None;
    for character in command.chars() {
        match (quote, character) {
            (Some(active), value) if value == active => quote = None,
            (None, '"' | '\'') => quote = Some(character),
            (None, value) if value.is_whitespace() => {
                if !current.is_empty() {
                    tokens.push(std::mem::take(&mut current));
                }
            }
            _ => current.push(character),
        }
    }
    if !current.is_empty() {
        tokens.push(current);
    }
    tokens
}

fn is_inside_workspace(root: &Path, path: &Path) -> bool {
    let root = normalize_path(root);
    let path = normalize_path(path);
    path == root || path.starts_with(&(root + "/"))
}

fn normalize_path(path: &Path) -> String {
    let normalized = path.to_string_lossy().replace('\\', "/");
    if cfg!(windows) {
        normalized.to_lowercase()
    } else {
        normalized
    }
}

fn static_limit_kind_values(value: &Value) -> Vec<String> {
    let mut values = Vec::new();
    collect_static_limit_kind_values(value, &mut values);
    values
}

fn collect_static_limit_kind_values(value: &Value, values: &mut Vec<String>) {
    match value {
        Value::Object(object) => {
            if let Some(kind) = object.get("static_limit_kind").and_then(Value::as_str) {
                values.push(kind.to_string());
            }
            for child in object.values() {
                collect_static_limit_kind_values(child, values);
            }
        }
        Value::Array(items) => {
            for item in items {
                collect_static_limit_kind_values(item, values);
            }
        }
        _ => {}
    }
}

fn contains_text_static_limit(value: &Value) -> bool {
    match value {
        Value::Object(object) => {
            object.iter().any(|(key, child)| {
                key.starts_with("static_limit")
                    && key != "static_limit_kind"
                    && child.as_str().is_some_and(|text| !text.trim().is_empty())
            }) || object.values().any(contains_text_static_limit)
        }
        Value::Array(items) => items.iter().any(contains_text_static_limit),
        _ => false,
    }
}

fn known_static_limit_kind(kind: &str) -> bool {
    [
        StaticLimitKind::DynamicDispatch,
        StaticLimitKind::Metaprogramming,
        StaticLimitKind::MissingImportGraph,
        StaticLimitKind::DecoratorIndirection,
        StaticLimitKind::MockedModule,
        StaticLimitKind::UnsupportedSyntax,
    ]
    .iter()
    .any(|known| known.as_str() == kind)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::fs;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn root() -> PathBuf {
        PathBuf::from("/workspace")
    }

    fn context(enabled_languages: &[LanguageId]) -> GapArtifactValidationContext<'_> {
        GapArtifactValidationContext {
            root: Path::new("/workspace"),
            enabled_languages,
        }
    }

    fn first_action() -> Value {
        json!({
            "schema_version": "0.1",
            "tool": "ripr",
            "kind": "first_useful_action",
            "root": ".",
            "status": "actionable",
            "selected": {
                "seam_id": "seam-1",
                "path": "src/pricing.rs",
                "line": 88
            },
            "target": {
                "file": "tests/pricing.rs",
                "related_test": "tests/pricing.rs::below_threshold"
            },
            "commands": {
                "verify": "ripr agent verify --root . --json",
                "receipt": "ripr agent receipt --root . --json"
            }
        })
    }

    fn actionable_gaps_report() -> Value {
        json!({
            "schema_version": "0.1",
            "tool": "ripr",
            "report": "actionable-gaps",
            "scope": "repo",
            "status": "advisory",
            "source_report": "target/ripr/reports/lane1-evidence-audit.json",
            "summary": {
                "actionable_gaps": 1,
                "packets_emitted": 1,
                "public_projection_eligible_packets": 1,
                "public_projection_excluded_packets": 0
            },
            "run_limitations": [],
            "packets": [
                {
                    "canonical_gap_id": "gap:rust:pricing-boundary",
                    "evidence_class": "predicate_boundary",
                    "gap_state": "actionable",
                    "actionability": "extend_related_test",
                    "source_file": "src/pricing.rs",
                    "primary_anchor": {
                        "file": "src/pricing.rs",
                        "line": 42
                    },
                    "repair_kind": "add_boundary_assertion",
                    "target_test_type": "boundary_discriminator",
                    "target_test": "tests/pricing.rs::below_threshold_has_no_discount",
                    "assertion_shape": "assert_eq!(price(threshold, threshold), expected)",
                    "recommended_repair": "Add the equality-boundary assertion.",
                    "why": "Related tests reach the seam but miss equality at the threshold.",
                    "related_test_or_observer": {
                        "file": "tests/pricing.rs",
                        "name": "below_threshold_has_no_discount",
                        "line": 10
                    },
                    "verify_command": "ripr agent verify --root . --json",
                    "repair_route_source": "canonical_item.repair_route",
                    "verify_command_source": "canonical_item.verify_command",
                    "receipt_command_or_path": "ripr agent receipt --root . --json",
                    "receipt_source": "canonical_item.receipt_command",
                    "public_projection_eligible": true,
                    "projection_exclusion_reasons": [],
                    "raw_findings": [
                        {
                            "file": "src/pricing.rs",
                            "line": 42,
                            "kind": "weakly_exposed",
                            "language": "rust",
                            "language_status": "stable"
                        }
                    ],
                    "confidence_basis": "static_only",
                    "must_not_change": [
                        "Do not infer actionability from raw static class."
                    ]
                }
            ],
            "must_not_infer": [
                "do not claim mutation execution or runtime proof from this packet"
            ]
        })
    }

    fn temp_root(label: &str) -> Result<PathBuf, String> {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|err| format!("system time before epoch: {err}"))?;
        Ok(std::env::temp_dir().join(format!(
            "ripr-lsp-gap-artifacts-{label}-{}-{}",
            std::process::id(),
            now.as_nanos()
        )))
    }

    fn preview_gap_ledger() -> Value {
        json!({
            "schema_version": "0.1",
            "tool": "ripr",
            "kind": "gap_decision_ledger",
            "root": ".",
            "status": "advisory",
            "records": [
                {
                    "gap_id": "gap:py:pricing",
                    "canonical_gap_id": "gap:py:pricing",
                    "kind": "MissingBoundaryAssertion",
                    "language": "python",
                    "language_status": "preview",
                    "gap_state": "actionable",
                    "repairability": "repairable",
                    "static_limit_kind": "missing_import_graph",
                    "repair_route": {
                        "route_kind": "AddBoundaryAssertion",
                        "target_file": "tests/test_pricing.py",
                        "related_test": "tests/test_pricing.py::test_discount_boundary"
                    },
                    "verification_commands": [
                        "ripr agent verify --root . --json"
                    ],
                    "receipt_command": "ripr agent receipt --root . --json",
                    "projection_eligibility": {
                        "lsp_diagnostic": {
                            "eligible": true,
                            "reason": "local_file_scope"
                        }
                    }
                }
            ]
        })
    }

    fn no_action_gap_record() -> Value {
        json!({
            "gap_id": "gap:rust:observed",
            "canonical_gap_id": "gap:rust:observed",
            "kind": "NoActionAlreadyObserved",
            "language": "rust",
            "language_status": "stable",
            "scope": "repo_scoped",
            "evidence_class": "already_observed",
            "gap_state": "already_observed",
            "policy_state": "resolved",
            "repairability": "no_action",
            "projection_eligibility": {
                "lsp_diagnostic": {
                    "eligible": false,
                    "reason": "no_user_repair_needed"
                }
            }
        })
    }

    #[test]
    fn gap_artifact_state_helpers_classify_status_preview_and_static_limits() -> Result<(), String>
    {
        let artifact = preview_gap_ledger();
        let mut validated =
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust, LanguageId::Python]))
                .map_err(|err| format!("{err:?}"))?;

        assert!(validated.is_actionable_gap());
        assert!(!validated.is_no_action_gap());
        assert!(validated.is_preview());
        assert!(validated.has_static_limit());

        for status in [
            "already_improved",
            "baseline_only",
            "no_actionable_seam",
            "suppressed",
            "acknowledged",
            "waived",
        ] {
            validated.gap_state = Some(status.to_string());
            assert!(validated.is_no_action_gap(), "{status}");
            assert!(!validated.is_actionable_gap(), "{status}");
        }

        validated.gap_state = Some("unchanged_after_attempt".to_string());
        assert!(!validated.is_no_action_gap());
        assert!(!validated.is_actionable_gap());

        validated.language_status = Some(LanguageStatus::Stable);
        assert!(!validated.is_preview());
        validated.static_limit_kinds.clear();
        validated.has_text_static_limit = false;
        assert!(!validated.has_static_limit());
        validated.has_text_static_limit = true;
        assert!(validated.has_static_limit());
        Ok(())
    }

    #[test]
    fn rejection_kind_strings_cover_fail_closed_reasons() {
        let cases = [
            (
                GapArtifactRejection::DisabledLanguage("python".to_string()),
                "disabled_language",
            ),
            (
                GapArtifactRejection::MalformedArtifact("bad artifact"),
                "malformed_artifact",
            ),
            (
                GapArtifactRejection::MalformedCommandPayload("bad command".to_string()),
                "malformed_command_payload",
            ),
            (GapArtifactRejection::MissingIdentity, "missing_identity"),
            (
                GapArtifactRejection::OutOfWorkspacePath("../outside.py".to_string()),
                "out_of_workspace_path",
            ),
            (GapArtifactRejection::StaleArtifact, "stale_artifact"),
            (
                GapArtifactRejection::UnavailableLanguage("python".to_string()),
                "unavailable_language",
            ),
            (
                GapArtifactRejection::UnsupportedSchema("9.9".to_string()),
                "unsupported_schema",
            ),
            (
                GapArtifactRejection::UnsupportedStaticLimitKind("runtime_magic".to_string()),
                "unsupported_static_limit_kind",
            ),
            (
                GapArtifactRejection::UnsupportedKind("unknown".to_string()),
                "unsupported_kind",
            ),
            (
                GapArtifactRejection::WrongRoot("/other/workspace".to_string()),
                "wrong_root",
            ),
        ];

        for (rejection, expected) in cases {
            assert_eq!(rejection.as_str(), expected);
        }
    }

    #[test]
    fn first_useful_action_validates_read_only_projection_inputs() -> Result<(), String> {
        let artifact = first_action();
        let validated = validate_gap_artifact(&artifact, &context(&[LanguageId::Rust]))
            .map_err(|err| format!("{err:?}"))?;

        assert_eq!(validated.kind, GapArtifactKind::FirstUsefulAction);
        assert_eq!(validated.root.as_deref(), Some("."));
        assert_eq!(validated.identities[0].seam_id.as_deref(), Some("seam-1"));
        assert_eq!(validated.gap_state.as_deref(), Some("actionable"));
        assert_eq!(validated.related_paths.len(), 3);
        assert_eq!(validated.verify_commands.len(), 1);
        assert_eq!(validated.receipt_commands.len(), 1);
        Ok(())
    }

    #[test]
    fn actionable_gaps_report_validates_read_only_queue_inputs() -> Result<(), String> {
        let artifact = actionable_gaps_report();
        let validated = validate_gap_artifact(&artifact, &context(&[LanguageId::Rust]))
            .map_err(|err| format!("{err:?}"))?;

        assert_eq!(validated.kind, GapArtifactKind::ActionableGaps);
        assert_eq!(
            validated.identities[0].canonical_gap_id.as_deref(),
            Some("gap:rust:pricing-boundary")
        );
        assert_eq!(validated.language, Some(LanguageId::Rust));
        assert_eq!(validated.language_status, Some(LanguageStatus::Stable));
        assert_eq!(validated.gap_state.as_deref(), Some("actionable"));
        assert!(validated.is_actionable_gap());
        assert!(validated.is_safe_projection_input());
        assert_eq!(validated.verify_commands.len(), 1);
        assert_eq!(validated.receipt_commands.len(), 1);
        assert!(
            validated
                .related_paths
                .iter()
                .any(|path| path == "tests/pricing.rs::below_threshold_has_no_discount")
        );
        Ok(())
    }

    #[test]
    fn actionable_gaps_report_allows_empty_no_action_queue() -> Result<(), String> {
        let artifact = json!({
            "schema_version": "0.1",
            "tool": "ripr",
            "report": "actionable-gaps",
            "scope": "repo",
            "status": "advisory",
            "summary": {
                "actionable_gaps": 0,
                "packets_emitted": 0,
                "public_projection_eligible_packets": 0,
                "public_projection_excluded_packets": 0
            },
            "run_limitations": [],
            "packets": []
        });

        let validated = validate_gap_artifact(&artifact, &context(&[LanguageId::Rust]))
            .map_err(|err| format!("{err:?}"))?;

        assert_eq!(validated.kind, GapArtifactKind::ActionableGaps);
        assert!(validated.identities.is_empty());
        assert_eq!(validated.gap_state.as_deref(), Some("no_actionable_seam"));
        assert!(validated.is_no_action_gap());
        assert!(validated.is_safe_projection_input());
        Ok(())
    }

    #[test]
    fn actionable_gaps_report_rejects_empty_queue_without_completed_summary() {
        let artifact = json!({
            "schema_version": "0.1",
            "tool": "ripr",
            "report": "actionable-gaps",
            "scope": "repo",
            "status": "advisory",
            "run_limitations": [],
            "packets": []
        });

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedArtifact(
                "empty actionable-gaps report must carry completed zero-count summary"
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_limited_run_artifacts() {
        let mut artifact = actionable_gaps_report();
        artifact["run_limitations"] = json!([
            {
                "category": "lane1_repo_exposure_timeout",
                "phase": "repo_exposure_generation",
                "repair_route": "inspect repo-exposure latency trace"
            }
        ]);

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedArtifact(
                "actionable-gaps report must not carry run_limitations for editor projection"
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_actionable_packet_without_identity() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["canonical_gap_id"] = json!(null);

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MissingIdentity)
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_projection_excluded_actionable_packets() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["repair_route_source"] = json!("missing");
        artifact["packets"][0]["public_projection_eligible"] = json!(false);
        artifact["packets"][0]["projection_exclusion_reasons"] = json!(["missing_repair_route"]);

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedArtifact(
                "actionable packet must carry canonical repair_route_source"
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_public_projection_exclusion_reasons() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["public_projection_eligible"] = json!(true);
        artifact["packets"][0]["projection_exclusion_reasons"] = json!(["missing_receipt_path"]);

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedArtifact(
                "actionable packet must not carry projection_exclusion_reasons"
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_missing_repair_guidance() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["repair_kind"] = json!("repair_route_unknown");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedArtifact(
                "actionable packet must carry repair_kind"
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_missing_receipt_command_or_path() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["receipt_command_or_path"] = json!(null);

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedArtifact(
                "actionable packet must carry receipt_command_or_path"
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_missing_verify_command() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["verify_command"] = json!("verify_command_unknown");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedArtifact(
                "actionable packet must carry verify_command"
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_compound_verify_command() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["verify_command"] =
            json!("ripr agent verify --root . --json; cargo test");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedCommandPayload(
                "ripr agent verify --root . --json; cargo test".to_string()
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_missing_confidence_basis() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["confidence_basis"] = json!(null);

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedArtifact(
                "actionable packet must carry confidence_basis"
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_disabled_language_in_later_packet() -> Result<(), String> {
        let mut artifact = actionable_gaps_report();
        let python_packet = json!({
            "canonical_gap_id": "gap:python:pricing-boundary",
            "evidence_class": "predicate_boundary",
            "gap_state": "actionable",
            "actionability": "extend_related_test",
            "language": "python",
            "language_status": "preview",
            "source_file": "src/pricing.py",
            "repair_kind": "add_boundary_assertion",
            "target_test_type": "boundary_discriminator",
            "target_test": "tests/test_pricing.py::test_boundary",
            "assertion_shape": "assert price(threshold, threshold) == expected",
            "verify_command": "ripr agent verify --root . --json",
            "repair_route_source": "canonical_item.repair_route",
            "verify_command_source": "canonical_item.verify_command",
            "receipt_command_or_path": "ripr agent receipt --root . --json",
            "receipt_source": "canonical_item.receipt_command",
            "public_projection_eligible": true,
            "projection_exclusion_reasons": [],
            "confidence_basis": "static_only"
        });
        artifact["packets"]
            .as_array_mut()
            .ok_or_else(|| "packets fixture must be an array".to_string())?
            .push(python_packet);

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::DisabledLanguage("python".to_string()))
        );
        Ok(())
    }

    #[test]
    fn actionable_gaps_report_rejects_unsafe_related_path() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["source_file"] = json!("../outside.rs");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::OutOfWorkspacePath(
                "../outside.rs".to_string()
            ))
        );
    }

    #[test]
    fn actionable_gaps_report_rejects_unsafe_verify_command() {
        let mut artifact = actionable_gaps_report();
        artifact["packets"][0]["verify_command"] =
            json!("ripr agent verify --root ../outside --json");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedCommandPayload(
                "ripr agent verify --root ../outside --json".to_string()
            ))
        );
    }

    #[test]
    fn preview_gap_ledger_validates_only_when_language_is_enabled() -> Result<(), String> {
        let artifact = preview_gap_ledger();
        let disabled = validate_gap_artifact(&artifact, &context(&[LanguageId::Rust]));
        assert_eq!(
            disabled,
            Err(GapArtifactRejection::DisabledLanguage("python".to_string()))
        );

        let enabled =
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust, LanguageId::Python]))
                .map_err(|err| format!("{err:?}"))?;
        assert_eq!(enabled.kind, GapArtifactKind::GapDecisionLedger);
        assert_eq!(enabled.language, Some(LanguageId::Python));
        assert_eq!(enabled.language_status, Some(LanguageStatus::Preview));
        assert_eq!(
            enabled.static_limit_kinds,
            vec!["missing_import_graph".to_string()]
        );
        assert_eq!(
            enabled.identities[0].canonical_gap_id.as_deref(),
            Some("gap:py:pricing")
        );
        Ok(())
    }

    #[test]
    fn gap_ledger_summary_uses_all_records_for_actionability_and_preview_status()
    -> Result<(), String> {
        let mut artifact = preview_gap_ledger();
        artifact["records"]
            .as_array_mut()
            .ok_or_else(|| "expected records array".to_string())?
            .insert(0, no_action_gap_record());

        let validated =
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust, LanguageId::Python]))
                .map_err(|err| format!("{err:?}"))?;

        assert_eq!(validated.identities.len(), 2);
        assert_eq!(validated.gap_state.as_deref(), Some("actionable"));
        assert!(validated.is_actionable_gap());
        assert!(validated.is_preview());
        Ok(())
    }

    #[test]
    fn gap_ledger_summary_reports_no_action_only_when_all_records_are_no_action()
    -> Result<(), String> {
        let artifact = json!({
            "schema_version": "0.1",
            "tool": "ripr",
            "kind": "gap_decision_ledger",
            "root": ".",
            "status": "advisory",
            "records": [
                no_action_gap_record(),
                {
                    "gap_id": "gap:rust:suppressed",
                    "canonical_gap_id": "gap:rust:suppressed",
                    "kind": "MissingBoundaryAssertion",
                    "language": "rust",
                    "language_status": "stable",
                    "scope": "repo_scoped",
                    "evidence_class": "predicate_boundary",
                    "gap_state": "suppressed",
                    "policy_state": "suppressed",
                    "repairability": "repairable",
                    "projection_eligibility": {
                        "lsp_diagnostic": {
                            "eligible": false,
                            "reason": "suppressed"
                        }
                    }
                }
            ]
        });

        let validated = validate_gap_artifact(&artifact, &context(&[LanguageId::Rust]))
            .map_err(|err| format!("{err:?}"))?;

        assert_eq!(validated.gap_state.as_deref(), Some("no_actionable_seam"));
        assert!(validated.is_no_action_gap());
        assert!(!validated.is_actionable_gap());
        Ok(())
    }

    #[test]
    fn gap_ledger_validation_checks_languages_for_every_record() -> Result<(), String> {
        let mut artifact = preview_gap_ledger();
        artifact["records"]
            .as_array_mut()
            .ok_or_else(|| "expected records array".to_string())?
            .insert(0, no_action_gap_record());

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::DisabledLanguage("python".to_string()))
        );
        Ok(())
    }

    #[test]
    fn validation_rejects_wrong_root() {
        let mut artifact = first_action();
        artifact["root"] = json!("/other/workspace");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::WrongRoot(
                "/other/workspace".to_string()
            ))
        );
    }

    #[test]
    fn validation_rejects_unsupported_schema() {
        let mut artifact = first_action();
        artifact["schema_version"] = json!("9.9");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::UnsupportedSchema("9.9".to_string()))
        );
    }

    #[test]
    fn validation_rejects_missing_identity() {
        let mut artifact = first_action();
        artifact["selected"] = json!({"path": "src/pricing.rs"});

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MissingIdentity)
        );
    }

    #[test]
    fn validation_rejects_stale_artifact() {
        let mut artifact = first_action();
        artifact["status"] = json!("stale");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::StaleArtifact)
        );
    }

    #[test]
    fn validation_rejects_out_of_workspace_related_paths() {
        let mut artifact = first_action();
        artifact["target"]["file"] = json!("../outside.rs");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::OutOfWorkspacePath(
                "../outside.rs".to_string()
            ))
        );
    }

    #[test]
    fn validation_rejects_malformed_command_payloads() {
        let mut artifact = first_action();
        artifact["commands"]["verify"] = json!("ripr agent verify --root ../outside --json");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust])),
            Err(GapArtifactRejection::MalformedCommandPayload(
                "ripr agent verify --root ../outside --json".to_string()
            ))
        );
    }

    #[test]
    fn validation_rejects_unregistered_static_limit_kind() {
        let mut artifact = preview_gap_ledger();
        artifact["records"][0]["static_limit_kind"] = json!("runtime_magic");

        assert_eq!(
            validate_gap_artifact(&artifact, &context(&[LanguageId::Rust, LanguageId::Python])),
            Err(GapArtifactRejection::UnsupportedStaticLimitKind(
                "runtime_magic".to_string()
            ))
        );
    }

    #[test]
    fn evidence_record_validates_text_static_limits_without_parsing_action_semantics()
    -> Result<(), String> {
        let artifact = json!({
            "schema_version": "0.1",
            "seam_id": "seam-2",
            "canonical_gap_id": "gap:rust:seam-2",
            "language": "rust",
            "language_status": "stable",
            "location": {
                "file": "src/lib.rs",
                "line": 12
            },
            "canonical_item": {
                "gap_state": "static_limitation",
                "verify_command": "ripr agent verify --root . --json"
            },
            "evidence_path": {},
            "static_limit_detail": "helper indirection hides the observed value"
        });

        let validated = validate_gap_artifact(&artifact, &context(&[LanguageId::Rust]))
            .map_err(|err| format!("{err:?}"))?;
        assert_eq!(validated.kind, GapArtifactKind::EvidenceRecord);
        assert!(validated.has_text_static_limit);
        assert!(validated.static_limit_kinds.is_empty());
        Ok(())
    }

    #[test]
    fn command_root_accepts_quoted_workspace_path() {
        let workspace = root();
        let command = "ripr agent verify --root \"/workspace\" --json";

        assert!(command_payload_is_safe(&workspace, command));
    }

    #[test]
    fn workspace_gap_artifact_loader_validates_known_report_paths() -> Result<(), String> {
        let root = temp_root("loader")?;
        let first_action_path = root.join(DEFAULT_FIRST_USEFUL_ACTION_OUT);
        let parent = first_action_path
            .parent()
            .ok_or_else(|| format!("missing parent for {}", first_action_path.display()))?;
        fs::create_dir_all(parent).map_err(|err| format!("create {}: {err}", parent.display()))?;
        fs::write(&first_action_path, first_action().to_string())
            .map_err(|err| format!("write {}: {err}", first_action_path.display()))?;

        let actionable_path = root.join(DEFAULT_ACTIONABLE_GAPS_OUT);
        let parent = actionable_path
            .parent()
            .ok_or_else(|| format!("missing parent for {}", actionable_path.display()))?;
        fs::create_dir_all(parent).map_err(|err| format!("create {}: {err}", parent.display()))?;
        fs::write(&actionable_path, actionable_gaps_report().to_string())
            .map_err(|err| format!("write {}: {err}", actionable_path.display()))?;

        let report = validate_workspace_gap_artifact_report(&root, &[LanguageId::Rust]);
        assert_eq!(report.artifacts.len(), 2);
        assert!(report.rejections.is_empty());
        assert!(
            report
                .artifacts
                .iter()
                .any(|artifact| artifact.kind == GapArtifactKind::ActionableGaps)
        );
        assert!(
            report
                .artifacts
                .iter()
                .any(|artifact| artifact.kind == GapArtifactKind::FirstUsefulAction)
        );

        fs::remove_dir_all(&root).map_err(|err| format!("remove {}: {err}", root.display()))?;
        Ok(())
    }

    #[test]
    fn workspace_gap_artifact_loader_reports_rejections_for_present_invalid_artifacts()
    -> Result<(), String> {
        let root = temp_root("loader-rejection")?;
        let report_path = root.join(DEFAULT_FIRST_USEFUL_ACTION_OUT);
        let parent = report_path
            .parent()
            .ok_or_else(|| format!("missing parent for {}", report_path.display()))?;
        fs::create_dir_all(parent).map_err(|err| format!("create {}: {err}", parent.display()))?;
        fs::write(&report_path, "{")
            .map_err(|err| format!("write {}: {err}", report_path.display()))?;

        let report = validate_workspace_gap_artifact_report(&root, &[LanguageId::Rust]);
        assert!(report.artifacts.is_empty());
        assert_eq!(
            report.rejections,
            vec![GapArtifactRejection::MalformedArtifact(
                "gap artifact JSON must parse"
            )]
        );
        assert_eq!(report.rejections[0].as_str(), "malformed_artifact");

        fs::remove_dir_all(&root).map_err(|err| format!("remove {}: {err}", root.display()))?;
        Ok(())
    }
}
