use crate::{core::DomainReason, ErrorCategory, StructError};

use super::{
    snapshot::{ErrorIdentity, ErrorSnapshot, StableErrorSnapshot},
    ErrorIdentityProvider, ErrorMetadata, MetadataValue, OperationContext, SourceFrame,
};

#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct DiagnosticReport {
    pub reason: String,
    pub detail: Option<String>,
    pub position: Option<String>,
    pub want: Option<String>,
    pub path: Option<String>,
    pub context: Vec<OperationContext>,
    pub root_metadata: ErrorMetadata,
    pub source_frames: Vec<SourceFrame>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderMode {
    Compact,
    Verbose,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Visibility {
    Public,
    Internal,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExposureDecision {
    pub http_status: u16,
    pub visibility: Visibility,
    pub default_hints: Vec<&'static str>,
    pub retryable: bool,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct ErrorProtocolSnapshot {
    pub identity: ErrorIdentity,
    pub decision: ExposureDecision,
    pub report: DiagnosticReport,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorHttpResponse {
    pub status: u16,
    pub code: String,
    pub category: ErrorCategory,
    pub message: String,
    pub visibility: Visibility,
    pub hints: Vec<String>,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorCliResponse {
    pub code: String,
    pub category: ErrorCategory,
    pub summary: String,
    pub detail: String,
    pub visibility: Visibility,
    pub hints: Vec<String>,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct ErrorLogResponse {
    pub code: String,
    pub category: ErrorCategory,
    pub reason: String,
    pub detail: Option<String>,
    pub operation: Option<String>,
    pub path: Option<String>,
    pub visibility: Visibility,
    pub hints: Vec<String>,
    pub root_metadata: ErrorMetadata,
    pub context: Vec<OperationContext>,
    pub source_frames: Vec<SourceFrame>,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorRpcResponse {
    pub status: u16,
    pub code: String,
    pub category: ErrorCategory,
    pub reason: String,
    pub detail: Option<String>,
    pub visibility: Visibility,
    pub hints: Vec<String>,
    pub retryable: bool,
}

pub trait ExposurePolicy {
    fn http_status(&self, _identity: &ErrorIdentity) -> u16 {
        500
    }

    fn visibility(&self, _identity: &ErrorIdentity) -> Visibility {
        Visibility::Internal
    }

    fn default_hints(&self, _identity: &ErrorIdentity) -> &'static [&'static str] {
        &[]
    }

    fn retryable(&self, _identity: &ErrorIdentity) -> bool {
        false
    }

    fn decide(&self, identity: &ErrorIdentity) -> ExposureDecision {
        ExposureDecision {
            http_status: self.http_status(identity),
            visibility: self.visibility(identity),
            default_hints: self.default_hints(identity).to_vec(),
            retryable: self.retryable(identity),
        }
    }
}
pub trait ErrorRenderer {
    type Output;

    fn render(&self, report: &DiagnosticReport) -> Self::Output;
}

#[derive(Debug, Clone, PartialEq)]
pub struct ExposureView {
    identity: ErrorIdentity,
    report: DiagnosticReport,
}

impl ExposureView {
    pub fn new(identity: ErrorIdentity, report: DiagnosticReport) -> Self {
        Self { identity, report }
    }

    pub fn identity(&self) -> &ErrorIdentity {
        &self.identity
    }

    pub fn report(&self) -> &DiagnosticReport {
        &self.report
    }

    pub fn into_parts(self) -> (ErrorIdentity, DiagnosticReport) {
        (self.identity, self.report)
    }

    pub fn render_with<R>(&self, renderer: R) -> R::Output
    where
        R: ErrorRenderer,
    {
        renderer.render(&self.report)
    }

    pub fn http_status(&self, exposure_policy: &impl ExposurePolicy) -> u16 {
        exposure_policy.http_status(&self.identity)
    }

    pub fn visibility(&self, exposure_policy: &impl ExposurePolicy) -> Visibility {
        exposure_policy.visibility(&self.identity)
    }

    pub fn default_hints(&self, exposure_policy: &impl ExposurePolicy) -> &'static [&'static str] {
        exposure_policy.default_hints(&self.identity)
    }

    pub fn decision(&self, exposure_policy: &impl ExposurePolicy) -> ExposureDecision {
        exposure_policy.decide(&self.identity)
    }

    pub fn snapshot(&self, exposure_policy: &impl ExposurePolicy) -> ErrorProtocolSnapshot {
        ErrorProtocolSnapshot {
            identity: self.identity.clone(),
            decision: self.decision(exposure_policy),
            report: self.report.clone(),
        }
    }

    pub fn http_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorHttpResponse {
        self.snapshot(exposure_policy).http_response()
    }

    pub fn cli_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorCliResponse {
        self.snapshot(exposure_policy).cli_response()
    }

    pub fn log_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorLogResponse {
        self.snapshot(exposure_policy).log_response()
    }

    pub fn rpc_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorRpcResponse {
        self.snapshot(exposure_policy).rpc_response()
    }

    pub fn render_user_debug(&self, exposure_policy: &impl ExposurePolicy) -> String {
        self.snapshot(exposure_policy).render_user_debug()
    }

    pub fn render_user_debug_redacted(
        &self,
        exposure_policy: &impl ExposurePolicy,
        redact_policy: &impl RedactPolicy,
    ) -> String {
        self.snapshot(exposure_policy)
            .render_user_debug_redacted(redact_policy)
    }

    pub fn render_debug_summary(&self, exposure_policy: &impl ExposurePolicy) -> String {
        self.render_user_debug(exposure_policy)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextDiagnosticRenderer {
    mode: RenderMode,
}

impl TextDiagnosticRenderer {
    pub fn new(mode: RenderMode) -> Self {
        Self { mode }
    }
}

impl ErrorRenderer for TextDiagnosticRenderer {
    type Output = String;

    fn render(&self, report: &DiagnosticReport) -> Self::Output {
        match self.mode {
            RenderMode::Compact => report.render_compact(),
            RenderMode::Verbose => report.render_verbose(),
        }
    }
}

#[doc(hidden)]
#[allow(dead_code)]
pub type TextReportRenderer = TextDiagnosticRenderer;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DefaultExposurePolicy;

impl ExposurePolicy for DefaultExposurePolicy {
    fn http_status(&self, identity: &ErrorIdentity) -> u16 {
        match identity.category {
            ErrorCategory::Biz => 400,
            ErrorCategory::Conf | ErrorCategory::Logic | ErrorCategory::Sys => 500,
        }
    }

    fn visibility(&self, identity: &ErrorIdentity) -> Visibility {
        match identity.category {
            ErrorCategory::Biz => Visibility::Public,
            ErrorCategory::Conf | ErrorCategory::Logic | ErrorCategory::Sys => Visibility::Internal,
        }
    }

    fn default_hints(&self, identity: &ErrorIdentity) -> &'static [&'static str] {
        match identity.code.as_str() {
            "sys.io_error" => &["check filesystem state", "verify file permissions"],
            "sys.network_error" => &["check network connectivity", "retry the request"],
            "sys.timeout" => &["retry later", "inspect downstream service latency"],
            "conf.core_invalid" | "conf.feature_invalid" | "conf.dynamic_invalid" => {
                &["check configuration values", "validate config source"]
            }
            _ => &[],
        }
    }

    fn retryable(&self, identity: &ErrorIdentity) -> bool {
        matches!(identity.code.as_str(), "sys.network_error" | "sys.timeout")
    }
}

pub trait RedactPolicy {
    fn redact_key(&self, _key: &str) -> bool {
        false
    }

    fn redact_value(&self, _key: Option<&str>, value: &str) -> Option<String> {
        Some(value.to_string())
    }
}

impl<T: DomainReason> StructError<T> {
    pub fn report(&self) -> DiagnosticReport {
        self.snapshot().report()
    }

    pub fn into_report(self) -> DiagnosticReport {
        self.into_snapshot().into_report()
    }

    pub fn report_redacted(&self, policy: &impl RedactPolicy) -> DiagnosticReport {
        self.report().redacted(policy)
    }
}

impl<T> StructError<T>
where
    T: DomainReason + ErrorIdentityProvider,
{
    pub fn exposure_view(&self) -> ExposureView {
        ExposureView::new(self.identity_snapshot(), self.report())
    }

    pub fn into_exposure_view(self) -> ExposureView {
        let identity = self.identity_snapshot();
        let report = self.into_report();
        ExposureView::new(identity, report)
    }

    pub fn exposure_snapshot(
        &self,
        exposure_policy: &impl ExposurePolicy,
    ) -> ErrorProtocolSnapshot {
        self.exposure_view().snapshot(exposure_policy)
    }

    pub fn into_exposure_snapshot(
        self,
        exposure_policy: &impl ExposurePolicy,
    ) -> ErrorProtocolSnapshot {
        self.into_exposure_view().snapshot(exposure_policy)
    }

    pub fn http_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorHttpResponse {
        self.exposure_snapshot(exposure_policy).http_response()
    }

    pub fn cli_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorCliResponse {
        self.exposure_snapshot(exposure_policy).cli_response()
    }

    pub fn log_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorLogResponse {
        self.exposure_snapshot(exposure_policy).log_response()
    }

    pub fn rpc_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorRpcResponse {
        self.exposure_snapshot(exposure_policy).rpc_response()
    }

    pub fn render_user_debug(&self, exposure_policy: &impl ExposurePolicy) -> String {
        self.exposure_snapshot(exposure_policy).render_user_debug()
    }

    pub fn render_user_debug_redacted(
        &self,
        exposure_policy: &impl ExposurePolicy,
        redact_policy: &impl RedactPolicy,
    ) -> String {
        self.exposure_snapshot(exposure_policy)
            .render_user_debug_redacted(redact_policy)
    }

    pub fn render_debug_summary(&self, exposure_policy: &impl ExposurePolicy) -> String {
        self.render_user_debug(exposure_policy)
    }
}

impl From<&ErrorSnapshot> for DiagnosticReport {
    fn from(value: &ErrorSnapshot) -> Self {
        value.report()
    }
}

impl From<ErrorSnapshot> for DiagnosticReport {
    fn from(value: ErrorSnapshot) -> Self {
        value.into_report()
    }
}

impl<T: DomainReason> From<&StructError<T>> for DiagnosticReport {
    fn from(value: &StructError<T>) -> Self {
        value.report()
    }
}

impl<T: DomainReason> From<StructError<T>> for DiagnosticReport {
    fn from(value: StructError<T>) -> Self {
        value.into_report()
    }
}

impl From<&StableErrorSnapshot> for DiagnosticReport {
    fn from(value: &StableErrorSnapshot) -> Self {
        value.report()
    }
}

impl From<StableErrorSnapshot> for DiagnosticReport {
    fn from(value: StableErrorSnapshot) -> Self {
        value.into_report()
    }
}

impl DiagnosticReport {
    pub fn render(&self, mode: RenderMode) -> String {
        self.render_with(TextDiagnosticRenderer::new(mode))
    }

    pub fn render_with<R>(&self, renderer: R) -> R::Output
    where
        R: ErrorRenderer,
    {
        renderer.render(self)
    }

    pub fn redacted(&self, policy: &impl RedactPolicy) -> Self {
        Self {
            reason: redact_required_text(Some("reason"), &self.reason, policy),
            detail: redact_optional_text(Some("detail"), self.detail.as_deref(), policy),
            position: redact_optional_text(Some("position"), self.position.as_deref(), policy),
            want: redact_optional_text(Some("want"), self.want.as_deref(), policy),
            path: redact_optional_text(Some("path"), self.path.as_deref(), policy),
            context: self
                .context
                .iter()
                .cloned()
                .map(|ctx| redact_context(ctx, policy))
                .collect(),
            root_metadata: redact_metadata(&self.root_metadata, policy),
            source_frames: self
                .source_frames
                .iter()
                .cloned()
                .map(|frame| redact_frame(frame, policy))
                .collect(),
        }
    }

    pub fn render_redacted(&self, mode: RenderMode, policy: &impl RedactPolicy) -> String {
        self.redacted(policy).render(mode)
    }

    fn render_compact(&self) -> String {
        let mut out = self.reason.clone();
        if let Some(detail) = &self.detail {
            out.push_str(": ");
            out.push_str(detail);
        }
        out
    }

    fn render_verbose(&self) -> String {
        let mut lines = Vec::new();
        lines.push(format!("reason: {}", self.reason));

        if let Some(detail) = &self.detail {
            lines.push(format!("detail: {detail}"));
        }
        if let Some(position) = &self.position {
            lines.push(format!("position: {position}"));
        }
        if let Some(want) = &self.want {
            lines.push(format!("want: {want}"));
        }
        if let Some(path) = &self.path {
            if self.want.as_deref() != Some(path.as_str()) {
                lines.push(format!("path: {path}"));
            }
        }
        if !self.root_metadata.is_empty() {
            lines.push(format!("root_metadata: {:?}", self.root_metadata.as_map()));
        }
        if !self.context.is_empty() {
            lines.push("context:".to_string());
            for (idx, ctx) in self.context.iter().enumerate() {
                lines.push(format!("  [{idx}] {}", ctx.to_string().trim_end()));
            }
        }
        if !self.source_frames.is_empty() {
            lines.push("source_frames:".to_string());
            for frame in &self.source_frames {
                let mut frame_line = format!("  [{}] {}", frame.index, frame.message);
                if let Some(reason) = &frame.reason {
                    frame_line.push_str(&format!(" reason={reason}"));
                }
                if let Some(want) = &frame.want {
                    frame_line.push_str(&format!(" want={want}"));
                }
                if let Some(path) = &frame.path {
                    frame_line.push_str(&format!(" path={path}"));
                }
                if !frame.metadata.is_empty() {
                    frame_line.push_str(&format!(" metadata={:?}", frame.metadata.as_map()));
                }
                if frame.is_root_cause {
                    frame_line.push_str(" root_cause=true");
                }
                lines.push(frame_line);
            }
        }

        lines.join("\n")
    }
}

impl DiagnosticReport {
    pub fn exposure_identity(&self) -> ErrorIdentity {
        let category = if self.reason.contains("configuration error") {
            ErrorCategory::Conf
        } else {
            ErrorCategory::Sys
        };

        ErrorIdentity {
            code: "report.unclassified".to_string(),
            category,
            reason: self.reason.clone(),
            detail: self.detail.clone(),
            position: self.position.clone(),
            want: self.want.clone(),
            path: self.path.clone(),
        }
    }

    pub fn http_status(&self, exposure_policy: &impl ExposurePolicy) -> u16 {
        exposure_policy.http_status(&self.exposure_identity())
    }

    pub fn visibility(&self, exposure_policy: &impl ExposurePolicy) -> Visibility {
        exposure_policy.visibility(&self.exposure_identity())
    }

    pub fn default_hints(&self, exposure_policy: &impl ExposurePolicy) -> &'static [&'static str] {
        exposure_policy.default_hints(&self.exposure_identity())
    }

    pub fn decision(&self, exposure_policy: &impl ExposurePolicy) -> ExposureDecision {
        exposure_policy.decide(&self.exposure_identity())
    }

    pub fn exposure_snapshot(
        &self,
        exposure_policy: &impl ExposurePolicy,
    ) -> ErrorProtocolSnapshot {
        ErrorProtocolSnapshot {
            identity: self.exposure_identity(),
            decision: self.decision(exposure_policy),
            report: self.clone(),
        }
    }

    pub fn http_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorHttpResponse {
        self.exposure_snapshot(exposure_policy).http_response()
    }

    pub fn cli_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorCliResponse {
        self.exposure_snapshot(exposure_policy).cli_response()
    }

    pub fn log_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorLogResponse {
        self.exposure_snapshot(exposure_policy).log_response()
    }

    pub fn rpc_response(&self, exposure_policy: &impl ExposurePolicy) -> ErrorRpcResponse {
        self.exposure_snapshot(exposure_policy).rpc_response()
    }

    #[cfg(feature = "serde_json")]
    pub fn to_exposure_snapshot_json(
        &self,
        exposure_policy: &impl ExposurePolicy,
    ) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.exposure_snapshot(exposure_policy))
    }

    pub fn exposure_view(self, identity: ErrorIdentity) -> ExposureView {
        ExposureView::new(identity, self)
    }

    #[cfg(feature = "serde_json")]
    pub fn to_http_error_json(
        &self,
        exposure_policy: &impl ExposurePolicy,
    ) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.http_response(exposure_policy))
    }

    #[cfg(feature = "serde_json")]
    pub fn to_cli_error_json(
        &self,
        exposure_policy: &impl ExposurePolicy,
    ) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.cli_response(exposure_policy))
    }

    #[cfg(feature = "serde_json")]
    pub fn to_log_error_json(
        &self,
        exposure_policy: &impl ExposurePolicy,
    ) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.log_response(exposure_policy))
    }

    #[cfg(feature = "serde_json")]
    pub fn to_rpc_error_json(
        &self,
        exposure_policy: &impl ExposurePolicy,
    ) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.rpc_response(exposure_policy))
    }
}

impl ErrorProtocolSnapshot {
    pub fn render_user_debug(&self) -> String {
        let mut lines = Vec::new();
        lines.push(format!(
            "code          : {} ({:?})",
            self.identity.code, self.identity.category
        ));

        if let Some(detail) = self.report.detail.as_deref() {
            lines.push(format!("detail        : {detail}"));
        } else {
            lines.push(format!("detail        : {}", self.identity.reason));
        }

        lines.push(format!(
            "http          : {} {:?} retryable={}",
            self.decision.http_status, self.decision.visibility, self.decision.retryable
        ));

        if let Some(path) = self.identity.path.as_deref() {
            lines.push(format!("path          : {path}"));
        }

        let context_summary = self
            .report
            .context
            .iter()
            .flat_map(|ctx| ctx.context().items.iter())
            .map(|(key, value)| format!("{key}={value:?}"))
            .collect::<Vec<_>>()
            .join(", ");
        if !context_summary.is_empty() {
            lines.push(format!("context       : {context_summary}"));
        }

        if let Some(component) = self.report.root_metadata.get_str("component.name") {
            lines.push(format!("component     : {component}"));
        } else if !self.report.root_metadata.is_empty() {
            lines.push(format!(
                "metadata      : {}",
                format_metadata_summary(&self.report.root_metadata)
            ));
        }

        if let Some(source) = root_cause_source_frame(&self.report.source_frames) {
            lines.push(format!("source        : {}", source.message));
        }

        lines.join("\n")
    }

    pub fn render_user_debug_redacted(&self, redact_policy: &impl RedactPolicy) -> String {
        self.redacted(redact_policy).render_user_debug()
    }

    pub fn render_debug_summary(&self) -> String {
        self.render_user_debug()
    }

    pub fn http_response(&self) -> ErrorHttpResponse {
        ErrorHttpResponse {
            status: self.decision.http_status,
            code: self.identity.code.clone(),
            category: self.identity.category,
            message: match self.decision.visibility {
                Visibility::Public => self
                    .report
                    .detail
                    .clone()
                    .unwrap_or_else(|| self.report.reason.clone()),
                Visibility::Internal => self.identity.reason.clone(),
            },
            visibility: self.decision.visibility,
            hints: self
                .decision
                .default_hints
                .iter()
                .map(|hint| (*hint).to_string())
                .collect(),
        }
    }

    #[cfg(feature = "serde_json")]
    pub fn to_http_error_json(&self) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.http_response())
    }

    pub fn cli_response(&self) -> ErrorCliResponse {
        ErrorCliResponse {
            code: self.identity.code.clone(),
            category: self.identity.category,
            summary: self.report.render(RenderMode::Compact),
            detail: self.report.render(RenderMode::Verbose),
            visibility: self.decision.visibility,
            hints: self
                .decision
                .default_hints
                .iter()
                .map(|hint| (*hint).to_string())
                .collect(),
        }
    }

    #[cfg(feature = "serde_json")]
    pub fn to_cli_error_json(&self) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.cli_response())
    }

    pub fn log_response(&self) -> ErrorLogResponse {
        ErrorLogResponse {
            code: self.identity.code.clone(),
            category: self.identity.category,
            reason: self.identity.reason.clone(),
            detail: self.report.detail.clone(),
            operation: self.report.want.clone(),
            path: self.report.path.clone(),
            visibility: self.decision.visibility,
            hints: self
                .decision
                .default_hints
                .iter()
                .map(|hint| (*hint).to_string())
                .collect(),
            root_metadata: self.report.root_metadata.clone(),
            context: self.report.context.clone(),
            source_frames: self.report.source_frames.clone(),
        }
    }

    #[cfg(feature = "serde_json")]
    pub fn to_log_error_json(&self) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.log_response())
    }

    pub fn rpc_response(&self) -> ErrorRpcResponse {
        ErrorRpcResponse {
            status: self.decision.http_status,
            code: self.identity.code.clone(),
            category: self.identity.category,
            reason: self.identity.reason.clone(),
            detail: match self.decision.visibility {
                Visibility::Public => self.report.detail.clone(),
                Visibility::Internal => None,
            },
            visibility: self.decision.visibility,
            hints: self
                .decision
                .default_hints
                .iter()
                .map(|hint| (*hint).to_string())
                .collect(),
            retryable: self.decision.retryable,
        }
    }

    #[cfg(feature = "serde_json")]
    pub fn to_rpc_error_json(&self) -> serde_json::Result<serde_json::Value> {
        serde_json::to_value(self.rpc_response())
    }

    pub fn redacted(&self, policy: &impl RedactPolicy) -> Self {
        Self {
            identity: ErrorIdentity {
                code: self.identity.code.clone(),
                category: self.identity.category,
                reason: redact_required_text(Some("reason"), &self.identity.reason, policy),
                detail: redact_optional_text(
                    Some("detail"),
                    self.identity.detail.as_deref(),
                    policy,
                ),
                position: redact_optional_text(
                    Some("position"),
                    self.identity.position.as_deref(),
                    policy,
                ),
                want: redact_optional_text(Some("want"), self.identity.want.as_deref(), policy),
                path: redact_optional_text(Some("path"), self.identity.path.as_deref(), policy),
            },
            decision: self.decision.clone(),
            report: self.report.redacted(policy),
        }
    }
}

fn root_cause_source_frame(source_frames: &[SourceFrame]) -> Option<&SourceFrame> {
    source_frames
        .iter()
        .find(|frame| frame.is_root_cause)
        .or_else(|| source_frames.last())
}

fn format_metadata_summary(metadata: &ErrorMetadata) -> String {
    metadata
        .iter()
        .map(|(key, value)| format!("{key}={}", format_metadata_value(value)))
        .collect::<Vec<_>>()
        .join(", ")
}

fn format_metadata_value(value: &MetadataValue) -> String {
    match value {
        MetadataValue::String(value) => format!("{value:?}"),
        MetadataValue::Bool(value) => value.to_string(),
        MetadataValue::I64(value) => value.to_string(),
        MetadataValue::U64(value) => value.to_string(),
    }
}

fn redact_optional_text(
    key: Option<&str>,
    value: Option<&str>,
    policy: &impl RedactPolicy,
) -> Option<String> {
    value.and_then(|value| policy.redact_value(key, value))
}

fn redact_context(mut ctx: OperationContext, policy: &impl RedactPolicy) -> OperationContext {
    let mut redacted_items = Vec::with_capacity(ctx.context().items.len());
    for (key, value) in &ctx.context().items {
        let kept = if policy.redact_key(key) {
            policy
                .redact_value(Some(key.as_str()), value)
                .or_else(|| Some("<redacted>".to_string()))
        } else {
            policy.redact_value(Some(key.as_str()), value)
        };

        if let Some(value) = kept {
            redacted_items.push((key.clone(), value));
        }
    }

    ctx.context_mut_for_report().items = redacted_items;
    let redacted_want = redact_optional_text(Some("want"), ctx.target().as_deref(), policy);
    let redacted_action = redact_optional_text(Some("action"), ctx.action().as_deref(), policy);
    let redacted_locator = redact_optional_text(Some("locator"), ctx.locator().as_deref(), policy);
    let redacted_path = ctx
        .path()
        .iter()
        .filter_map(|segment| redact_optional_text(Some("path"), Some(segment.as_str()), policy))
        .collect::<Vec<_>>();
    ctx.replace_target_for_report(redacted_want);
    ctx.replace_action_for_report(redacted_action);
    ctx.replace_locator_for_report(redacted_locator);
    ctx.replace_path_for_report(redacted_path);
    ctx.replace_metadata_for_report(redact_metadata(ctx.metadata(), policy));
    ctx
}

fn redact_metadata(metadata: &ErrorMetadata, policy: &impl RedactPolicy) -> ErrorMetadata {
    let mut redacted = ErrorMetadata::new();
    for (key, value) in metadata.iter() {
        match value {
            MetadataValue::String(value) => {
                if policy.redact_key(key) {
                    if let Some(value) = policy
                        .redact_value(Some(key.as_str()), value)
                        .or_else(|| Some("<redacted>".to_string()))
                    {
                        redacted.insert(key.clone(), value);
                    }
                } else if let Some(value) = policy.redact_value(Some(key.as_str()), value) {
                    redacted.insert(key.clone(), value);
                }
            }
            MetadataValue::Bool(value) => {
                if !policy.redact_key(key) {
                    redacted.insert(key.clone(), *value);
                }
            }
            MetadataValue::I64(value) => {
                if !policy.redact_key(key) {
                    redacted.insert(key.clone(), *value);
                }
            }
            MetadataValue::U64(value) => {
                if !policy.redact_key(key) {
                    redacted.insert(key.clone(), *value);
                }
            }
        }
    }
    redacted
}

fn redact_frame(mut frame: SourceFrame, policy: &impl RedactPolicy) -> SourceFrame {
    frame.message = redact_required_text(Some("source.message"), &frame.message, policy);
    frame.display = redact_optional_text(Some("source.display"), frame.display.as_deref(), policy);
    frame.debug = redact_required_text(Some("source.debug"), &frame.debug, policy);
    frame.detail = redact_optional_text(Some("detail"), frame.detail.as_deref(), policy);
    frame.reason = redact_optional_text(Some("source.reason"), frame.reason.as_deref(), policy);
    frame.want = redact_optional_text(Some("want"), frame.want.as_deref(), policy);
    frame.path = redact_optional_text(Some("path"), frame.path.as_deref(), policy);
    frame.metadata = redact_metadata(&frame.metadata, policy);
    frame
}

fn redact_required_text(key: Option<&str>, value: &str, policy: &impl RedactPolicy) -> String {
    policy
        .redact_value(key, value)
        .unwrap_or_else(|| "<redacted>".to_string())
}

#[cfg(test)]
mod tests {
    use crate::{
        core::DomainReason,
        core::{
            context::ContextRecord, ErrorIdentity, ErrorMetadata, SourceFrame, StableErrorSnapshot,
            StableSnapshotContextFrame, StableSnapshotSourceFrame,
        },
        ErrorCategory, ErrorCode, ErrorIdentityProvider, OperationContext, StructError, UvsReason,
    };

    use super::{
        DefaultExposurePolicy, DiagnosticReport, ErrorCliResponse, ErrorHttpResponse,
        ErrorLogResponse, ErrorProtocolSnapshot, ErrorRenderer, ErrorRpcResponse, ExposureDecision,
        ExposurePolicy, ExposureView, RedactPolicy, RenderMode, TextDiagnosticRenderer, Visibility,
    };
    use crate::core::STABLE_SNAPSHOT_SCHEMA_VERSION;
    #[derive(Debug)]
    struct TestPolicy;

    impl RedactPolicy for TestPolicy {
        fn redact_key(&self, key: &str) -> bool {
            matches!(key, "token" | "password" | "config.secret")
        }

        fn redact_value(&self, _key: Option<&str>, _value: &str) -> Option<String> {
            Some("<redacted>".to_string())
        }
    }

    #[derive(Debug, Clone, PartialEq, thiserror::Error)]
    enum TestReason {
        #[error("test error")]
        TestError,
        #[error("{0}")]
        Uvs(UvsReason),
    }

    impl From<UvsReason> for TestReason {
        fn from(value: UvsReason) -> Self {
            Self::Uvs(value)
        }
    }

    impl DomainReason for TestReason {}

    impl ErrorCode for TestReason {
        fn error_code(&self) -> i32 {
            match self {
                TestReason::TestError => 1001,
                TestReason::Uvs(reason) => reason.error_code(),
            }
        }
    }

    impl ErrorIdentityProvider for TestReason {
        fn stable_code(&self) -> &'static str {
            match self {
                TestReason::TestError => "test.test_error",
                TestReason::Uvs(reason) => reason.stable_code(),
            }
        }

        fn error_category(&self) -> ErrorCategory {
            match self {
                TestReason::TestError => ErrorCategory::Logic,
                TestReason::Uvs(reason) => reason.error_category(),
            }
        }
    }

    #[test]
    fn test_report_contains_root_and_source_data() {
        let source = StructError::from(TestReason::TestError).with_context(
            OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
        );
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_context(
                OperationContext::doing("start engine").with_meta("component.name", "engine"),
            )
            .with_struct_source(source);

        let report = err.report();

        assert_eq!(report.reason, "system error");
        assert_eq!(
            report.root_metadata.get_str("component.name"),
            Some("engine")
        );
        assert_eq!(
            report.source_frames[0].metadata.get_str("config.kind"),
            Some("sink_defaults")
        );
    }

    #[test]
    fn test_struct_error_into_report_matches_borrowed_report() {
        let source = StructError::from(TestReason::TestError).with_context(
            OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
        );
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_context(
                OperationContext::doing("start engine").with_meta("component.name", "engine"),
            )
            .with_struct_source(source);

        let via_borrowed = err.report();
        let via_owned = err.into_report();

        assert_eq!(via_owned, via_borrowed);
    }

    #[test]
    fn test_report_from_struct_error_matches_report_methods() {
        let source = StructError::from(TestReason::TestError).with_context(
            OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
        );
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_context(
                OperationContext::doing("start engine").with_meta("component.name", "engine"),
            )
            .with_struct_source(source);

        let via_method = err.report();
        let via_borrowed = DiagnosticReport::from(&err);
        let via_owned = DiagnosticReport::from(err);

        assert_eq!(via_borrowed, via_method);
        assert_eq!(via_owned, via_method);
    }

    #[test]
    fn test_report_from_stable_snapshot_matches_report_methods() {
        let stable = StableErrorSnapshot {
            schema_version: STABLE_SNAPSHOT_SCHEMA_VERSION,
            reason: "system error".to_string(),
            detail: Some("outer detail".to_string()),
            position: None,
            want: Some("start engine".to_string()),
            path: Some("start engine".to_string()),
            context: vec![StableSnapshotContextFrame {
                target: Some("start engine".to_string()),
                action: None,
                locator: None,
                path: vec!["start engine".to_string()],
                metadata: ErrorMetadata::new(),
            }],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![StableSnapshotSourceFrame {
                index: 0,
                message: "db unavailable".to_string(),
                error_code: None,
                reason: None,
                want: Some("load config".to_string()),
                path: Some("load config / read".to_string()),
                detail: Some("inner detail".to_string()),
                metadata: ErrorMetadata::new(),
                is_root_cause: true,
            }],
        };

        let via_method = stable.report();
        let via_borrowed = DiagnosticReport::from(&stable);
        let via_owned = DiagnosticReport::from(stable);

        assert_eq!(via_borrowed, via_method);
        assert_eq!(via_owned, via_method);
    }

    #[test]
    fn test_report_verbose_render_includes_metadata() {
        let report = DiagnosticReport {
            reason: "test error".to_string(),
            detail: Some("failed".to_string()),
            position: None,
            want: Some("load".to_string()),
            path: Some("load / parse".to_string()),
            context: vec![],
            root_metadata: {
                let mut metadata = ErrorMetadata::new();
                metadata.insert("component.name", "engine");
                metadata
            },
            source_frames: vec![SourceFrame {
                index: 0,
                message: "inner".to_string(),
                display: None,
                debug: "inner".to_string(),
                type_name: None,
                error_code: None,
                reason: None,
                want: None,
                path: None,
                detail: None,
                metadata: {
                    let mut metadata = ErrorMetadata::new();
                    metadata.insert("config.kind", "sink_defaults");
                    metadata
                },
                is_root_cause: true,
            }],
        };

        let rendered = report.render(RenderMode::Verbose);
        assert!(rendered.contains("root_metadata"));
        assert!(rendered.contains("component.name"));
        assert!(rendered.contains("config.kind"));
    }

    #[test]
    fn test_text_report_renderer_matches_existing_render_output() {
        let report = DiagnosticReport {
            reason: "test error".to_string(),
            detail: Some("failed".to_string()),
            position: None,
            want: Some("load".to_string()),
            path: Some("load / parse".to_string()),
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![],
        };

        let renderer = TextDiagnosticRenderer::new(RenderMode::Verbose);
        let via_renderer = renderer.render(&report);
        let via_method = report.render(RenderMode::Verbose);

        assert_eq!(via_renderer, via_method);
    }

    #[test]
    fn test_render_with_uses_custom_renderer() {
        struct ReasonOnlyRenderer;

        impl ErrorRenderer for ReasonOnlyRenderer {
            type Output = String;

            fn render(&self, report: &DiagnosticReport) -> Self::Output {
                format!("only:{}", report.reason)
            }
        }

        let report = DiagnosticReport {
            reason: "test error".to_string(),
            detail: Some("failed".to_string()),
            position: None,
            want: None,
            path: None,
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![],
        };

        assert_eq!(report.render_with(ReasonOnlyRenderer), "only:test error");
    }

    #[test]
    fn test_default_exposure_policy_maps_category_to_http_status_and_visibility() {
        let exposure_policy = DefaultExposurePolicy;
        let biz_identity = ErrorIdentity {
            code: "biz.validation_error".to_string(),
            category: ErrorCategory::Biz,
            reason: "validation error".to_string(),
            detail: None,
            position: None,
            want: None,
            path: None,
        };
        let sys_identity = ErrorIdentity {
            code: "sys.io_error".to_string(),
            category: ErrorCategory::Sys,
            reason: "system error".to_string(),
            detail: None,
            position: None,
            want: None,
            path: None,
        };

        assert_eq!(exposure_policy.http_status(&biz_identity), 400);
        assert_eq!(exposure_policy.http_status(&sys_identity), 500);
        assert_eq!(
            exposure_policy.visibility(&biz_identity),
            Visibility::Public
        );
        assert_eq!(
            exposure_policy.visibility(&sys_identity),
            Visibility::Internal
        );
        assert_eq!(
            exposure_policy.default_hints(&sys_identity),
            ["check filesystem state", "verify file permissions"]
        );
        assert_eq!(
            exposure_policy.decide(&sys_identity),
            ExposureDecision {
                http_status: 500,
                visibility: Visibility::Internal,
                default_hints: vec!["check filesystem state", "verify file permissions"],
                retryable: false,
            }
        );
    }

    #[test]
    fn test_exposure_view_uses_explicit_identity_without_report_side_guessing() {
        let report = DiagnosticReport {
            reason: "system error".to_string(),
            detail: Some("disk offline".to_string()),
            position: None,
            want: Some("load config".to_string()),
            path: Some("load config".to_string()),
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![],
        };
        let identity = ErrorIdentity {
            code: "biz.validation_error".to_string(),
            category: ErrorCategory::Biz,
            reason: "validation error".to_string(),
            detail: None,
            position: None,
            want: None,
            path: None,
        };
        let exposure_policy = DefaultExposurePolicy;
        let view = report.exposure_view(identity.clone());

        assert_eq!(view.identity(), &identity);
        assert_eq!(view.http_status(&exposure_policy), 400);
        assert_eq!(view.visibility(&exposure_policy), Visibility::Public);
        assert_eq!(view.report().reason, "system error");
    }

    #[test]
    fn test_struct_error_exposure_view_uses_real_stable_identity() {
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_detail("engine bootstrap failed")
            .with_context(OperationContext::doing("start engine"));

        let exposure_view = err.exposure_view();
        let exposure_policy = DefaultExposurePolicy;

        assert_eq!(exposure_view.identity().code, "sys.io_error");
        assert_eq!(exposure_view.identity().category, ErrorCategory::Sys);
        assert_eq!(exposure_view.http_status(&exposure_policy), 500);
        assert_eq!(
            exposure_view.default_hints(&exposure_policy),
            ["check filesystem state", "verify file permissions"]
        );
        assert_eq!(
            exposure_view.decision(&exposure_policy),
            ExposureDecision {
                http_status: 500,
                visibility: Visibility::Internal,
                default_hints: vec!["check filesystem state", "verify file permissions"],
                retryable: false,
            }
        );
        assert_eq!(exposure_view.report().reason, "system error");
    }

    #[test]
    fn test_exposure_view_render_with_uses_underlying_report() {
        let report = DiagnosticReport {
            reason: "test error".to_string(),
            detail: Some("failed".to_string()),
            position: None,
            want: None,
            path: None,
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![],
        };
        let view = ExposureView::new(
            ErrorIdentity {
                code: "test.test_error".to_string(),
                category: ErrorCategory::Logic,
                reason: "test error".to_string(),
                detail: None,
                position: None,
                want: None,
                path: None,
            },
            report.clone(),
        );

        assert_eq!(
            view.render_with(TextDiagnosticRenderer::new(RenderMode::Compact)),
            report.render(RenderMode::Compact)
        );
    }

    #[test]
    fn test_exposure_view_snapshot_contains_identity_decision_and_report() {
        let report = DiagnosticReport {
            reason: "test error".to_string(),
            detail: Some("failed".to_string()),
            position: None,
            want: None,
            path: None,
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![],
        };
        let identity = ErrorIdentity {
            code: "biz.validation_error".to_string(),
            category: ErrorCategory::Biz,
            reason: "validation error".to_string(),
            detail: None,
            position: None,
            want: None,
            path: None,
        };
        let view = ExposureView::new(identity.clone(), report.clone());

        assert_eq!(
            view.snapshot(&DefaultExposurePolicy),
            ErrorProtocolSnapshot {
                identity,
                decision: ExposureDecision {
                    http_status: 400,
                    visibility: Visibility::Public,
                    default_hints: vec![],
                    retryable: false,
                },
                report,
            }
        );
    }

    #[test]
    fn test_report_decision_uses_exposure_identity_fallback() {
        let report = DiagnosticReport {
            reason: "configuration error".to_string(),
            detail: Some("invalid config".to_string()),
            position: None,
            want: None,
            path: None,
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![],
        };

        assert_eq!(
            report.decision(&DefaultExposurePolicy),
            ExposureDecision {
                http_status: 500,
                visibility: Visibility::Internal,
                default_hints: vec![],
                retryable: false,
            }
        );
    }

    #[cfg(feature = "serde_json")]
    #[test]
    fn test_exposure_snapshot_json_contains_identity_decision_and_report_sections() {
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_detail("engine bootstrap failed")
            .with_context(OperationContext::doing("start engine"));

        let json_value = err
            .report()
            .to_exposure_snapshot_json(&DefaultExposurePolicy)
            .expect("serialize exposure snapshot");

        assert_eq!(
            json_value["identity"]["code"],
            serde_json::json!("report.unclassified")
        );
        assert_eq!(
            json_value["decision"]["http_status"],
            serde_json::json!(500)
        );
        assert_eq!(
            json_value["decision"]["visibility"],
            serde_json::json!("Internal")
        );
        assert_eq!(
            json_value["report"]["reason"],
            serde_json::json!("system error")
        );
        assert_eq!(
            json_value["report"]["detail"],
            serde_json::json!("engine bootstrap failed")
        );
    }

    #[cfg(feature = "serde_json")]
    #[test]
    fn test_exposure_snapshot_json_keys_match_expected_shape() {
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_detail("engine bootstrap failed");

        let json_value = err
            .report()
            .to_exposure_snapshot_json(&DefaultExposurePolicy)
            .expect("serialize exposure snapshot");

        let mut top_level = json_value
            .as_object()
            .expect("exposure snapshot object")
            .keys()
            .cloned()
            .collect::<Vec<_>>();
        top_level.sort();

        let mut expected_top_level = ["identity", "decision", "report"]
            .into_iter()
            .map(str::to_string)
            .collect::<Vec<_>>();
        expected_top_level.sort();

        let mut decision_fields = json_value["decision"]
            .as_object()
            .expect("decision object")
            .keys()
            .cloned()
            .collect::<Vec<_>>();
        decision_fields.sort();

        let mut expected_decision_fields =
            ["http_status", "visibility", "default_hints", "retryable"]
                .into_iter()
                .map(str::to_string)
                .collect::<Vec<_>>();
        expected_decision_fields.sort();

        assert_eq!(top_level, expected_top_level);
        assert_eq!(decision_fields, expected_decision_fields);
    }

    #[test]
    fn test_http_response_projection_uses_detail_for_public_visibility() {
        let err = StructError::from(TestReason::Uvs(UvsReason::business_error()))
            .with_detail("order state invalid");

        assert_eq!(
            err.http_response(&DefaultExposurePolicy),
            ErrorHttpResponse {
                status: 400,
                code: "biz.business_error".to_string(),
                category: ErrorCategory::Biz,
                message: "order state invalid".to_string(),
                visibility: Visibility::Public,
                hints: vec![],
            }
        );
    }

    #[test]
    fn test_http_response_projection_uses_reason_for_internal_visibility() {
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_detail("disk offline");

        assert_eq!(
            err.http_response(&DefaultExposurePolicy),
            ErrorHttpResponse {
                status: 500,
                code: "sys.io_error".to_string(),
                category: ErrorCategory::Sys,
                message: "system error".to_string(),
                visibility: Visibility::Internal,
                hints: vec![
                    "check filesystem state".to_string(),
                    "verify file permissions".to_string(),
                ],
            }
        );
    }

    #[cfg(feature = "serde_json")]
    #[test]
    fn test_http_error_json_keys_match_expected_shape() {
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_detail("disk offline");

        let json_value = err
            .report()
            .to_http_error_json(&DefaultExposurePolicy)
            .expect("serialize http error");

        let mut keys = json_value
            .as_object()
            .expect("http error object")
            .keys()
            .cloned()
            .collect::<Vec<_>>();
        keys.sort();

        let mut expected = [
            "status",
            "code",
            "category",
            "message",
            "visibility",
            "hints",
        ]
        .into_iter()
        .map(str::to_string)
        .collect::<Vec<_>>();
        expected.sort();

        assert_eq!(keys, expected);
        assert_eq!(json_value["status"], serde_json::json!(500));
        assert_eq!(json_value["code"], serde_json::json!("report.unclassified"));
        assert_eq!(json_value["message"], serde_json::json!("system error"));
    }

    #[test]
    fn test_cli_response_projection_contains_summary_detail_and_hints() {
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_detail("disk offline");

        assert_eq!(
            err.cli_response(&DefaultExposurePolicy),
            ErrorCliResponse {
                code: "sys.io_error".to_string(),
                category: ErrorCategory::Sys,
                summary: "system error: disk offline".to_string(),
                detail: "reason: system error\ndetail: disk offline".to_string(),
                visibility: Visibility::Internal,
                hints: vec![
                    "check filesystem state".to_string(),
                    "verify file permissions".to_string(),
                ],
            }
        );
    }

    #[test]
    fn test_log_response_projection_contains_machine_facing_diagnostics() {
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_detail("disk offline")
            .with_std_source(std::io::Error::other("root cause"))
            .with_context(OperationContext::doing("load config").with_meta("tenant", "acme"));
        let mut root_metadata = ErrorMetadata::new();
        root_metadata.insert("tenant", "acme");

        assert_eq!(
            err.log_response(&DefaultExposurePolicy),
            ErrorLogResponse {
                code: "sys.io_error".to_string(),
                category: ErrorCategory::Sys,
                reason: "system error".to_string(),
                detail: Some("disk offline".to_string()),
                operation: Some("load config".to_string()),
                path: Some("load config".to_string()),
                visibility: Visibility::Internal,
                hints: vec![
                    "check filesystem state".to_string(),
                    "verify file permissions".to_string(),
                ],
                root_metadata,
                context: vec![OperationContext::doing("load config").with_meta("tenant", "acme")],
                source_frames: err.report().source_frames,
            }
        );
    }

    #[test]
    fn test_rpc_response_projection_hides_internal_detail_and_marks_retryable() {
        let err = StructError::from(TestReason::Uvs(UvsReason::timeout_error()))
            .with_detail("downstream rpc timeout");

        assert_eq!(
            err.rpc_response(&DefaultExposurePolicy),
            ErrorRpcResponse {
                status: 500,
                code: "sys.timeout".to_string(),
                category: ErrorCategory::Sys,
                reason: "timeout error".to_string(),
                detail: None,
                visibility: Visibility::Internal,
                hints: vec![
                    "retry later".to_string(),
                    "inspect downstream service latency".to_string(),
                ],
                retryable: true,
            }
        );
    }

    #[test]
    fn test_rpc_response_projection_keeps_public_detail() {
        let err = StructError::from(TestReason::Uvs(UvsReason::business_error()))
            .with_detail("order state invalid");

        assert_eq!(
            err.rpc_response(&DefaultExposurePolicy),
            ErrorRpcResponse {
                status: 400,
                code: "biz.business_error".to_string(),
                category: ErrorCategory::Biz,
                reason: "business logic error".to_string(),
                detail: Some("order state invalid".to_string()),
                visibility: Visibility::Public,
                hints: vec![],
                retryable: false,
            }
        );
    }

    #[test]
    fn test_exposure_snapshot_render_debug_summary_prefers_detail_path_context_and_component() {
        let snapshot = ErrorProtocolSnapshot {
            identity: ErrorIdentity {
                code: "biz.order_invalid".to_string(),
                category: ErrorCategory::Biz,
                reason: "invalid order".to_string(),
                detail: Some("order text must not be empty".to_string()),
                position: None,
                want: Some("place_order".to_string()),
                path: Some("place_order / parse order".to_string()),
            },
            decision: ExposureDecision {
                http_status: 400,
                visibility: Visibility::Public,
                default_hints: vec![],
                retryable: false,
            },
            report: DiagnosticReport {
                reason: "invalid order".to_string(),
                detail: Some("order text must not be empty".to_string()),
                position: None,
                want: Some("place_order".to_string()),
                path: Some("place_order / parse order".to_string()),
                context: vec![{
                    let mut ctx = OperationContext::doing("place_order");
                    ctx.record_field("user_id", "42");
                    ctx.record_field("order.raw", "");
                    ctx.record_meta("component.name", "order_service");
                    ctx
                }],
                root_metadata: {
                    let mut metadata = ErrorMetadata::new();
                    metadata.insert("component.name", "order_service");
                    metadata.insert("trace.secret", "prod-token");
                    metadata
                },
                source_frames: vec![],
            },
        };

        let rendered = snapshot.render_debug_summary();

        assert!(rendered.contains("code          : biz.order_invalid (Biz)"));
        assert!(rendered.contains("detail        : order text must not be empty"));
        assert!(rendered.contains("http          : 400 Public retryable=false"));
        assert!(rendered.contains("path          : place_order / parse order"));
        assert!(rendered.contains("context       : user_id=\"42\", order.raw=\"\""));
        assert!(rendered.contains("component     : order_service"));
        assert!(!rendered.contains("trace.secret"));
    }

    #[test]
    fn test_exposure_snapshot_render_debug_summary_falls_back_to_reason_and_source() {
        let snapshot = ErrorProtocolSnapshot {
            identity: ErrorIdentity {
                code: "sys.storage_full".to_string(),
                category: ErrorCategory::Sys,
                reason: "storage full".to_string(),
                detail: None,
                position: None,
                want: Some("place_order".to_string()),
                path: Some("place_order / save order".to_string()),
            },
            decision: ExposureDecision {
                http_status: 500,
                visibility: Visibility::Internal,
                default_hints: vec![],
                retryable: false,
            },
            report: DiagnosticReport {
                reason: "storage full".to_string(),
                detail: None,
                position: None,
                want: Some("place_order".to_string()),
                path: Some("place_order / save order".to_string()),
                context: vec![],
                root_metadata: ErrorMetadata::new(),
                source_frames: vec![SourceFrame {
                    index: 0,
                    message: "storage full".to_string(),
                    display: None,
                    debug: String::new(),
                    type_name: None,
                    error_code: None,
                    reason: None,
                    want: None,
                    path: None,
                    detail: None,
                    metadata: ErrorMetadata::new(),
                    is_root_cause: true,
                }],
            },
        };

        let rendered = snapshot.render_debug_summary();

        assert!(rendered.contains("detail        : storage full"));
        assert!(rendered.contains("source        : storage full"));
    }

    #[test]
    fn test_exposure_snapshot_render_debug_summary_prefers_root_cause_source_frame() {
        let snapshot = ErrorProtocolSnapshot {
            identity: ErrorIdentity {
                code: "sys.io_error".to_string(),
                category: ErrorCategory::Sys,
                reason: "system error".to_string(),
                detail: Some("save order failed".to_string()),
                position: None,
                want: Some("place_order".to_string()),
                path: Some("place_order / save order".to_string()),
            },
            decision: ExposureDecision {
                http_status: 500,
                visibility: Visibility::Internal,
                default_hints: vec![],
                retryable: false,
            },
            report: DiagnosticReport {
                reason: "system error".to_string(),
                detail: Some("save order failed".to_string()),
                position: None,
                want: Some("place_order".to_string()),
                path: Some("place_order / save order".to_string()),
                context: vec![],
                root_metadata: ErrorMetadata::new(),
                source_frames: vec![
                    SourceFrame {
                        index: 0,
                        message: "storage layer failed".to_string(),
                        display: None,
                        debug: String::new(),
                        type_name: None,
                        error_code: None,
                        reason: None,
                        want: None,
                        path: None,
                        detail: None,
                        metadata: ErrorMetadata::new(),
                        is_root_cause: false,
                    },
                    SourceFrame {
                        index: 1,
                        message: "disk offline".to_string(),
                        display: None,
                        debug: String::new(),
                        type_name: None,
                        error_code: None,
                        reason: None,
                        want: None,
                        path: None,
                        detail: None,
                        metadata: ErrorMetadata::new(),
                        is_root_cause: true,
                    },
                ],
            },
        };

        let rendered = snapshot.render_debug_summary();

        assert!(rendered.contains("source        : disk offline"));
        assert!(!rendered.contains("source        : storage layer failed"));
    }

    #[test]
    fn test_render_user_debug_aliases_match_existing_debug_summary() {
        let err = StructError::from(TestReason::Uvs(UvsReason::business_error()))
            .with_detail("order state invalid")
            .with_context({
                let mut ctx = OperationContext::doing("validate order");
                ctx.record_field("order_id", "A-1001");
                ctx.record_meta("component.name", "order_service");
                ctx
            });

        let via_struct = err.render_user_debug(&DefaultExposurePolicy);
        let via_struct_compat = err.render_debug_summary(&DefaultExposurePolicy);
        let via_view = err
            .exposure_view()
            .render_user_debug(&DefaultExposurePolicy);
        let via_snapshot = err
            .exposure_snapshot(&DefaultExposurePolicy)
            .render_user_debug();

        assert_eq!(via_struct, via_struct_compat);
        assert_eq!(via_struct, via_view);
        assert_eq!(via_struct, via_snapshot);
    }

    #[test]
    fn test_render_user_debug_redacted_masks_sensitive_fields() {
        let err = StructError::from(TestReason::TestError)
            .with_detail("token=abc")
            .with_context({
                let mut ctx = OperationContext::doing("load");
                ctx.record_field("token", "abc");
                ctx.record_meta("component.name", "order_service");
                ctx.record_meta("config.secret", "abc");
                ctx
            });

        let rendered = err.render_user_debug_redacted(&DefaultExposurePolicy, &TestPolicy);

        assert!(rendered.contains("<redacted>"));
        assert!(!rendered.contains("token=abc"));
        assert!(!rendered.contains("token=\"abc\""));
        assert!(!rendered.contains("config.secret"));
    }

    #[cfg(feature = "serde_json")]
    #[test]
    fn test_cli_error_json_keys_match_expected_shape() {
        let err = StructError::from(TestReason::Uvs(UvsReason::business_error()))
            .with_detail("order state invalid");

        let json_value = err
            .report()
            .to_cli_error_json(&DefaultExposurePolicy)
            .expect("serialize cli error");

        let mut keys = json_value
            .as_object()
            .expect("cli error object")
            .keys()
            .cloned()
            .collect::<Vec<_>>();
        keys.sort();

        let mut expected = [
            "code",
            "category",
            "summary",
            "detail",
            "visibility",
            "hints",
        ]
        .into_iter()
        .map(str::to_string)
        .collect::<Vec<_>>();
        expected.sort();

        assert_eq!(keys, expected);
        assert_eq!(json_value["code"], serde_json::json!("report.unclassified"));
        assert_eq!(
            json_value["summary"],
            serde_json::json!("business logic error: order state invalid")
        );
    }

    #[cfg(feature = "serde_json")]
    #[test]
    fn test_log_error_json_keys_match_expected_shape() {
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_detail("disk offline")
            .with_context(OperationContext::doing("load config"));

        let json_value = err
            .report()
            .to_log_error_json(&DefaultExposurePolicy)
            .expect("serialize log error");

        let mut keys = json_value
            .as_object()
            .expect("log error object")
            .keys()
            .cloned()
            .collect::<Vec<_>>();
        keys.sort();

        let mut expected = [
            "code",
            "category",
            "reason",
            "detail",
            "operation",
            "path",
            "visibility",
            "hints",
            "root_metadata",
            "context",
            "source_frames",
        ]
        .into_iter()
        .map(str::to_string)
        .collect::<Vec<_>>();
        expected.sort();

        assert_eq!(keys, expected);
        assert_eq!(json_value["code"], serde_json::json!("report.unclassified"));
        assert_eq!(json_value["reason"], serde_json::json!("system error"));
        assert_eq!(json_value["operation"], serde_json::json!("load config"));
    }

    #[cfg(feature = "serde_json")]
    #[test]
    fn test_rpc_error_json_keys_match_expected_shape() {
        let err = StructError::from(TestReason::Uvs(UvsReason::timeout_error()))
            .with_detail("downstream rpc timeout");

        let json_value = err
            .report()
            .to_rpc_error_json(&DefaultExposurePolicy)
            .expect("serialize rpc error");

        let mut keys = json_value
            .as_object()
            .expect("rpc error object")
            .keys()
            .cloned()
            .collect::<Vec<_>>();
        keys.sort();

        let mut expected = [
            "status",
            "code",
            "category",
            "reason",
            "detail",
            "visibility",
            "hints",
            "retryable",
        ]
        .into_iter()
        .map(str::to_string)
        .collect::<Vec<_>>();
        expected.sort();

        assert_eq!(keys, expected);
        assert_eq!(json_value["code"], serde_json::json!("report.unclassified"));
        assert_eq!(json_value["retryable"], serde_json::json!(false));
        assert_eq!(json_value["detail"], serde_json::Value::Null);
    }

    #[test]
    fn test_report_redaction_masks_sensitive_fields() {
        let err = StructError::from(TestReason::TestError)
            .with_detail("token=abc")
            .with_std_source(std::io::Error::other("token=abc"))
            .with_context(OperationContext::doing("load").with_meta("config.secret", "abc"));

        let rendered = err.render_redacted(RenderMode::Verbose, &TestPolicy);
        assert!(rendered.contains("<redacted>"));
        assert!(!rendered.contains("abc"));
    }

    #[test]
    fn test_report_redaction_masks_source_frame_message() {
        let err = StructError::from(TestReason::TestError)
            .with_std_source(std::io::Error::other("https://svc.local?token=abc"));

        let rendered = err.render_redacted(RenderMode::Verbose, &TestPolicy);

        assert!(rendered.contains("<redacted>"));
        assert!(!rendered.contains("svc.local"));
        assert!(!rendered.contains("token=abc"));
    }

    #[test]
    fn test_report_redaction_masks_source_frame_display() {
        let report = DiagnosticReport {
            reason: "test error".to_string(),
            detail: None,
            position: None,
            want: None,
            path: None,
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![SourceFrame {
                index: 0,
                message: "inner".to_string(),
                display: Some("inner token=abc".to_string()),
                debug: "debug".to_string(),
                type_name: None,
                error_code: None,
                reason: None,
                want: None,
                path: None,
                detail: None,
                metadata: ErrorMetadata::new(),
                is_root_cause: true,
            }],
        };

        let redacted = report.redacted(&TestPolicy);
        assert_eq!(
            redacted.source_frames[0].display.as_deref(),
            Some("<redacted>")
        );
        assert!(!redacted.source_frames[0]
            .display
            .as_deref()
            .unwrap()
            .contains("token=abc"));
    }

    #[test]
    fn test_report_redaction_masks_source_frame_debug() {
        let report = DiagnosticReport {
            reason: "test error".to_string(),
            detail: None,
            position: None,
            want: None,
            path: None,
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![SourceFrame {
                index: 0,
                message: "inner".to_string(),
                display: None,
                debug: "debug token=abc".to_string(),
                type_name: None,
                error_code: None,
                reason: None,
                want: None,
                path: None,
                detail: None,
                metadata: ErrorMetadata::new(),
                is_root_cause: true,
            }],
        };

        let redacted = report.redacted(&TestPolicy);
        assert_eq!(redacted.source_frames[0].debug, "<redacted>");
        assert!(!redacted.source_frames[0].debug.contains("token=abc"));
    }

    #[test]
    fn test_report_redaction_masks_root_and_frame_paths() {
        let report = DiagnosticReport {
            reason: "test error".to_string(),
            detail: None,
            position: Some("/srv/app/config.toml:10".to_string()),
            want: Some("load /srv/app/config.toml".to_string()),
            path: Some("load /srv/app/config.toml / parse".to_string()),
            context: vec![OperationContext::at("/srv/app/config.toml")],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![SourceFrame {
                index: 0,
                message: "inner".to_string(),
                display: None,
                debug: "debug".to_string(),
                type_name: None,
                error_code: None,
                reason: None,
                want: Some("open /srv/app/config.toml".to_string()),
                path: Some("open /srv/app/config.toml / read".to_string()),
                detail: None,
                metadata: ErrorMetadata::new(),
                is_root_cause: true,
            }],
        };

        #[derive(Debug)]
        struct PathPolicy;

        impl RedactPolicy for PathPolicy {
            fn redact_value(&self, key: Option<&str>, value: &str) -> Option<String> {
                match key {
                    Some("position") | Some("want") | Some("path") | Some("locator") => {
                        Some(value.replace("/srv/app/config.toml", "<path-redacted>"))
                    }
                    _ => Some(value.to_string()),
                }
            }
        }

        let rendered = report.render_redacted(RenderMode::Verbose, &PathPolicy);
        assert!(rendered.contains("<path-redacted>"));
        assert!(!rendered.contains("/srv/app/config.toml"));
    }

    #[test]
    fn test_report_redaction_masks_reason_fields() {
        let report = DiagnosticReport {
            reason: "tenant secret error".to_string(),
            detail: None,
            position: None,
            want: None,
            path: None,
            context: vec![],
            root_metadata: ErrorMetadata::new(),
            source_frames: vec![SourceFrame {
                index: 0,
                message: "inner".to_string(),
                display: None,
                debug: "debug".to_string(),
                type_name: None,
                error_code: None,
                reason: Some("tenant secret source".to_string()),
                want: None,
                path: None,
                detail: None,
                metadata: ErrorMetadata::new(),
                is_root_cause: true,
            }],
        };

        #[derive(Debug)]
        struct ReasonPolicy;

        impl RedactPolicy for ReasonPolicy {
            fn redact_value(&self, key: Option<&str>, value: &str) -> Option<String> {
                match key {
                    Some("reason") | Some("source.reason") => {
                        Some(value.replace("secret", "<redacted>"))
                    }
                    _ => Some(value.to_string()),
                }
            }
        }

        let redacted = report.redacted(&ReasonPolicy);
        assert_eq!(redacted.reason, "tenant <redacted> error");
        assert_eq!(
            redacted.source_frames[0].reason.as_deref(),
            Some("tenant <redacted> source")
        );
    }

    #[test]
    fn test_report_redaction_applies_value_hook_without_redact_key() {
        #[derive(Debug)]
        struct ValueOnlyPolicy;

        impl RedactPolicy for ValueOnlyPolicy {
            fn redact_value(&self, key: Option<&str>, value: &str) -> Option<String> {
                match key {
                    Some("detail") => Some("<detail-redacted>".to_string()),
                    Some("token") => Some("<token-redacted>".to_string()),
                    Some("config.secret") => Some("<secret-redacted>".to_string()),
                    _ => Some(value.to_string()),
                }
            }
        }

        let err = StructError::from(TestReason::TestError)
            .with_detail("token=abc")
            .with_context({
                let mut ctx = OperationContext::doing("load");
                ctx.record("token", "abc");
                ctx.record_meta("config.secret", "abc");
                ctx
            });

        let rendered = err.render_redacted(RenderMode::Verbose, &ValueOnlyPolicy);
        assert!(rendered.contains("<detail-redacted>"));
        assert!(rendered.contains("<token-redacted>"));
        assert!(rendered.contains("<secret-redacted>"));
        assert!(!rendered.contains("token=abc"));
        assert!(!rendered.contains("token: abc"));
        assert!(!rendered.contains("config.secret\": \"abc"));
    }

    #[cfg(feature = "serde")]
    #[test]
    fn test_report_serialization_supports_structured_export() {
        let source = StructError::from(TestReason::TestError).with_context(
            OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
        );
        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
            .with_context(
                OperationContext::doing("start engine").with_meta("component.name", "engine"),
            )
            .with_struct_source(source);

        let json_value = serde_json::to_value(err.report()).expect("serialize report");

        assert_eq!(json_value["reason"], serde_json::json!("system error"));
        assert_eq!(
            json_value["root_metadata"]["component.name"],
            serde_json::json!("engine")
        );
        assert_eq!(
            json_value["source_frames"][0]["metadata"]["config.kind"],
            serde_json::json!("sink_defaults")
        );
    }

    #[cfg(feature = "serde")]
    #[test]
    fn test_report_redacted_supports_structured_export() {
        let err = StructError::from(TestReason::TestError)
            .with_detail("token=abc")
            .with_std_source(std::io::Error::other("token=abc"))
            .with_context(OperationContext::doing("load").with_meta("config.secret", "abc"));

        let json_value =
            serde_json::to_value(err.report_redacted(&TestPolicy)).expect("serialize redacted");

        let encoded = serde_json::to_string(&json_value).expect("json string");
        assert!(encoded.contains("<redacted>"));
        assert!(!encoded.contains("token=abc"));
        assert!(!encoded.contains("\"abc\""));
    }
}
