//! Shared degradation vocabulary boundary.
//!
//! Degradation types describe partial availability without forcing every
//! command to treat configured-service outages or explicitly degraded paths as
//! fatal. Detailed contracts live here so lightweight consumers can share the
//! same vocabulary.

use serde::{Deserialize, Serialize};

/// Service availability state, returned alongside results from adapters.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServiceState {
    /// Service is connected and responding.
    Available,
    /// Service is not configured because no config was found from any source.
    NotConfigured,
    /// Service is configured but could not be reached.
    Unreachable {
        /// Adapter-provided diagnostic message for the failed connection.
        message: String,
    },
}

impl ServiceState {
    /// Returns true when the backing service is connected and responding.
    pub fn is_available(&self) -> bool {
        matches!(self, Self::Available)
    }
}

/// Setup validation issue with actionable guidance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupIssue {
    /// Name of the missing, invalid, or degraded resource.
    pub object_name: String,
    /// Store or service that owns the resource.
    pub store: String,
    /// Structured remediation guidance for callers to render.
    pub guidance: Guidance,
}

/// Structured guidance text for setup issues.
///
/// Callers render these fields; `gobby-core` does not format CLI output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Guidance {
    /// What is missing or wrong.
    pub problem: String,
    /// What the user should do.
    pub action: String,
    /// Optional command suggestion.
    pub command_hint: Option<String>,
}

/// Fatal errors that prevent a command from completing.
#[derive(Debug, Serialize, Deserialize, thiserror::Error)]
pub enum CoreError {
    /// Configuration was present but invalid for the requested operation.
    #[error("invalid configuration: {0}")]
    InvalidConfig(String),
    /// Two reachable hub DSNs point at different PostgreSQL clusters or databases.
    #[error(
        "conflicting Gobby PostgreSQL hubs: existing recorded hub identifies {existing_identity}; daemon hub identifies {daemon_identity}"
    )]
    HubConflict {
        /// DSN from the existing standalone/subset configuration.
        #[serde(serialize_with = "serialize_redacted_database_url")]
        existing_database_url: String,
        /// Cluster/database identity observed for the existing DSN.
        existing_identity: String,
        /// DSN reported by the daemon bootstrap/broker.
        #[serde(serialize_with = "serialize_redacted_database_url")]
        daemon_database_url: String,
        /// Cluster/database identity observed for the daemon DSN.
        daemon_identity: String,
    },
    /// A service required by this command could not be used.
    #[error("required service unavailable: {service} — {message}")]
    RequiredServiceUnavailable {
        /// Required service name.
        service: String,
        /// Diagnostic message explaining the unavailability.
        message: String,
    },
    /// A write operation failed after validation began.
    #[error("write failed: {0}")]
    WriteFailed(String),
    /// Input could not be parsed or failed integrity checks.
    #[error("corrupted input: {0}")]
    CorruptedInput(String),
}

fn serialize_redacted_database_url<S>(database_url: &str, serializer: S) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    serializer.serialize_str(&redact_database_url(database_url))
}

pub fn redact_database_url(database_url: &str) -> String {
    let without_fragment = database_url
        .split_once('#')
        .map_or(database_url, |(head, _)| head);
    let without_query = without_fragment
        .split_once('?')
        .map_or(without_fragment, |(head, _)| head);
    if let Some((scheme, rest)) = without_query.split_once("://") {
        let host_and_path = rest
            .rsplit_once('@')
            .map_or(rest, |(_, host_and_path)| host_and_path);
        format!("{scheme}://{host_and_path}")
    } else {
        redact_keyword_database_url(without_query)
    }
}

fn redact_keyword_database_url(database_url: &str) -> String {
    crate::libpq::split_keyword_dsn_tokens(database_url)
        .into_iter()
        .map(|token| {
            let Some((key, _value)) = token.split_once('=') else {
                return token.to_string();
            };
            if is_sensitive_keyword_dsn_key(key) {
                format!("{key}=<redacted>")
            } else {
                token.to_string()
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn is_sensitive_keyword_dsn_key(key: &str) -> bool {
    matches!(
        key.to_ascii_lowercase().as_str(),
        "password" | "passfile" | "sslpassword"
    )
}

/// Why a modality capability (transcription, translation, vision, or a daemon
/// capability probe) degraded.
///
/// The serialized snake_case names double as the marker strings written into
/// vault frontmatter (`transcription_degradation`, `vision_degradation`,
/// `media_degradation`) and daemon capability reports, so variants must keep
/// their names stable; use [`Self::as_str`] when embedding a marker in text.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModalityDegradationReason {
    /// Routing turned the capability off.
    Disabled,
    /// No endpoint is configured for the capability.
    MissingEndpoint,
    /// The endpoint rejected the configured credentials.
    Unauthorized,
    /// The endpoint could not be reached.
    Unreachable,
    /// The endpoint answered with an unexpected HTTP status.
    UnexpectedStatus,
    /// The capability depends on something that was already degraded upstream.
    Unavailable,
    /// Transcription was attempted and failed.
    TranscriptionError,
    /// Translation was attempted and failed.
    TranslationError,
    /// Translation support is not compiled in or configured.
    TranslationUnavailable,
    /// Vision extraction was attempted and failed.
    VisionError,
}

impl ModalityDegradationReason {
    /// The stable marker string for this reason (matches the serde name).
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Disabled => "disabled",
            Self::MissingEndpoint => "missing_endpoint",
            Self::Unauthorized => "unauthorized",
            Self::Unreachable => "unreachable",
            Self::UnexpectedStatus => "unexpected_status",
            Self::Unavailable => "unavailable",
            Self::TranscriptionError => "transcription_error",
            Self::TranslationError => "translation_error",
            Self::TranslationUnavailable => "translation_unavailable",
            Self::VisionError => "vision_error",
        }
    }
}

impl std::fmt::Display for ModalityDegradationReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Degradation states for partial results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DegradationKind {
    /// A configured service was unavailable during this operation.
    ServiceUnavailable {
        /// Optional service name.
        service: String,
        /// Availability state observed by the caller.
        state: ServiceState,
    },
    /// Search completed with fewer sources than requested.
    PartialSearch {
        /// Source names that contributed results.
        available: Vec<String>,
        /// Source names that could not contribute results.
        unavailable: Vec<String>,
    },
    /// Operation completed with capped or otherwise incomplete data.
    PartialData {
        /// Component whose data was incomplete.
        component: String,
        /// Human-readable detail about the partial data.
        message: String,
    },
    /// Index data may be stale because of content drift or age thresholds.
    StaleIndex {
        /// Paths whose indexed data may be stale.
        paths: Vec<String>,
    },
    /// Some artifacts were skipped during indexing.
    SkippedArtifacts {
        /// Number of skipped artifacts.
        count: usize,
        /// Human-readable reason the artifacts were skipped.
        reason: String,
    },
}

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

    #[test]
    fn modality_reason_markers_match_serde_names() {
        for reason in [
            ModalityDegradationReason::Disabled,
            ModalityDegradationReason::MissingEndpoint,
            ModalityDegradationReason::Unauthorized,
            ModalityDegradationReason::Unreachable,
            ModalityDegradationReason::UnexpectedStatus,
            ModalityDegradationReason::Unavailable,
            ModalityDegradationReason::TranscriptionError,
            ModalityDegradationReason::TranslationError,
            ModalityDegradationReason::TranslationUnavailable,
            ModalityDegradationReason::VisionError,
        ] {
            let serialized = serde_json::to_value(reason).expect("serialize reason");
            assert_eq!(
                serialized,
                serde_json::Value::String(reason.as_str().to_string()),
                "as_str and serde marker drifted for {reason:?}"
            );
            assert_eq!(reason.to_string(), reason.as_str());
        }
    }

    #[test]
    fn service_unavailable_degradation_is_not_fatal() {
        let unconfigured = ServiceState::NotConfigured;
        let unreachable = ServiceState::Unreachable {
            message: "connection refused".to_string(),
        };

        assert!(!unconfigured.is_available());
        assert!(!unreachable.is_available());

        let degradation = DegradationKind::ServiceUnavailable {
            service: "qdrant".to_string(),
            state: unconfigured,
        };
        let fatal = CoreError::RequiredServiceUnavailable {
            service: "postgres".to_string(),
            message: "hub is required for writes".to_string(),
        };

        assert!(matches!(
            degradation,
            DegradationKind::ServiceUnavailable {
                service,
                state: ServiceState::NotConfigured
            } if service == "qdrant"
        ));
        assert_eq!(
            fatal.to_string(),
            "required service unavailable: postgres — hub is required for writes"
        );
    }

    #[test]
    fn guidance_is_structured() {
        let guidance = Guidance {
            problem: "BM25 index missing".to_string(),
            action: "run attached setup validation".to_string(),
            command_hint: Some("gobby setup validate".to_string()),
        };

        assert_eq!(guidance.problem, "BM25 index missing");
        assert_eq!(guidance.action, "run attached setup validation");
        assert_eq!(
            guidance.command_hint.as_deref(),
            Some("gobby setup validate")
        );
    }

    #[test]
    fn core_error_serialization_roundtrip() {
        let invalid_config = CoreError::InvalidConfig("missing project id".to_string());
        let encoded = serde_json::to_string(&invalid_config).expect("serialize invalid config");
        let decoded: CoreError =
            serde_json::from_str(&encoded).expect("deserialize invalid config");
        assert!(matches!(
            decoded,
            CoreError::InvalidConfig(message) if message == "missing project id"
        ));

        let unavailable = CoreError::RequiredServiceUnavailable {
            service: "postgres".to_string(),
            message: "connection refused".to_string(),
        };
        let encoded = serde_json::to_string(&unavailable).expect("serialize unavailable");
        let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize unavailable");
        assert!(matches!(
            decoded,
            CoreError::RequiredServiceUnavailable { service, message }
                if service == "postgres" && message == "connection refused"
        ));

        let hub_conflict = CoreError::HubConflict {
            existing_database_url: "postgres://existing".to_string(),
            existing_identity: "existing-cluster/existing-db".to_string(),
            daemon_database_url: "postgres://daemon".to_string(),
            daemon_identity: "daemon-cluster/daemon-db".to_string(),
        };
        let encoded = serde_json::to_string(&hub_conflict).expect("serialize hub conflict");
        let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize hub conflict");
        assert!(matches!(
            decoded,
            CoreError::HubConflict {
                existing_database_url,
                existing_identity,
                daemon_database_url,
                daemon_identity,
            } if existing_database_url == "postgres://existing"
                && existing_identity == "existing-cluster/existing-db"
                && daemon_database_url == "postgres://daemon"
                && daemon_identity == "daemon-cluster/daemon-db"
        ));
    }

    #[test]
    fn hub_conflict_display_and_json_redact_database_urls() {
        let conflict = CoreError::HubConflict {
            existing_database_url: "postgresql://user:secret@standalone/gobby?sslmode=require#frag"
                .to_string(),
            existing_identity: "cluster-a/gobby".to_string(),
            daemon_database_url: "postgresql://daemon:secret@daemon/gobby?application_name=gobby"
                .to_string(),
            daemon_identity: "cluster-b/gobby".to_string(),
        };

        let message = conflict.to_string();
        assert!(message.contains("cluster-a/gobby"));
        assert!(message.contains("cluster-b/gobby"));
        assert!(!message.contains("postgresql://"));
        assert!(!message.contains("secret"));
        assert!(!message.contains("sslmode"));
        assert!(!message.contains("application_name"));

        let encoded = serde_json::to_string(&conflict).expect("serialize hub conflict");
        assert!(encoded.contains("postgresql://standalone/gobby"));
        assert!(encoded.contains("postgresql://daemon/gobby"));
        assert!(!encoded.contains("secret"));
        assert!(!encoded.contains("sslmode"));
        assert!(!encoded.contains("application_name"));
        assert!(!encoded.contains("frag"));
    }

    #[test]
    fn keyword_database_url_redacts_sensitive_values_case_insensitively() {
        let redacted = redact_database_url(
            "host=localhost user=app PASSWORD='secret value' dbname=gobby sslpassword=topsecret",
        );

        assert!(redacted.contains("host=localhost"));
        assert!(redacted.contains("user=app"));
        assert!(redacted.contains("dbname=gobby"));
        assert!(redacted.contains("PASSWORD=<redacted>"));
        assert!(redacted.contains("sslpassword=<redacted>"));
        assert!(!redacted.contains("secret value"));
        assert!(!redacted.contains("topsecret"));
    }

    #[test]
    fn hub_conflict_json_redacts_keyword_database_urls() {
        let conflict = CoreError::HubConflict {
            existing_database_url: "host=standalone user=app password=secret dbname=gobby"
                .to_string(),
            existing_identity: "cluster-a/gobby".to_string(),
            daemon_database_url: "HOST=daemon USER=daemon PASSFILE='/tmp/pgpass' dbname=gobby"
                .to_string(),
            daemon_identity: "cluster-b/gobby".to_string(),
        };

        let encoded = serde_json::to_string(&conflict).expect("serialize hub conflict");

        assert!(encoded.contains("host=standalone"));
        assert!(encoded.contains("password=<redacted>"));
        assert!(encoded.contains("PASSFILE=<redacted>"));
        assert!(!encoded.contains("secret"));
        assert!(!encoded.contains("/tmp/pgpass"));
    }
}
