// file: kb_lib/src/detect/solana_ws.rs

//! Solana WebSocket detection bridge.
//!
//! This module converts raw Solana JSON-RPC WebSocket notifications into
//! normalized observations and, when possible, token and pool candidates.

/// Result of one Solana WebSocket detection pass.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum SolanaWsDetectionOutcome {
    /// The notification is currently ignored by the detector.
    Ignored,
    /// A technical observation was stored.
    ObservationRecorded {
        /// Persisted observation id.
        observation_id: i64,
    },
    /// A token candidate was registered.
    TokenCandidateRegistered {
        /// Persistence result.
        result: crate::DetectionTokenCandidateResult,
    },
    /// A pool candidate was registered.
    PoolCandidateRegistered {
        /// Persistence result.
        result: crate::DetectionPoolCandidateResult,
    },
}

/// Detection service for Solana WebSocket notifications.
#[derive(Debug, Clone)]
pub struct SolanaWsDetectionService {
    /// Shared persistence façade.
    persistence: crate::DetectionPersistenceService,
}

impl SolanaWsDetectionService {
    /// Creates a new Solana WebSocket detection service.
    pub fn new(persistence: crate::DetectionPersistenceService) -> Self {
        return Self { persistence };
    }

    /// Returns the shared persistence façade.
    pub fn persistence(&self) -> &crate::DetectionPersistenceService {
        return &self.persistence;
    }

    /// Processes one Solana WebSocket JSON-RPC notification.
    pub async fn process_notification(
        &self,
        endpoint_name: std::option::Option<std::string::String>,
        notification: &crate::JsonRpcWsNotification,
    ) -> Result<crate::SolanaWsDetectionOutcome, crate::Error> {
        let observation_kind_option =
            map_notification_method_to_observation_kind(notification.method.as_str());
        let observation_kind = match observation_kind_option {
            Some(observation_kind) => observation_kind,
            None => return Ok(crate::SolanaWsDetectionOutcome::Ignored),
        };
        let token_candidate_result =
            self.try_register_token_candidate(endpoint_name.clone(), notification).await;
        match token_candidate_result {
            Ok(Some(result)) => {
                return Ok(crate::SolanaWsDetectionOutcome::TokenCandidateRegistered { result });
            },
            Ok(None) => {},
            Err(error) => return Err(error),
        }
        let pool_candidate_result =
            self.try_register_pool_candidate(endpoint_name.clone(), notification).await;
        match pool_candidate_result {
            Ok(Some(result)) => {
                return Ok(crate::SolanaWsDetectionOutcome::PoolCandidateRegistered { result });
            },
            Ok(None) => {},
            Err(error) => return Err(error),
        }
        let payload = build_notification_payload(notification);
        let object_key = build_object_key(
            notification.method.as_str(),
            &notification.params.result,
            notification.params.subscription,
        );
        let slot =
            extract_slot_from_result(notification.method.as_str(), &notification.params.result);
        let observation_input = crate::DetectionObservationInput::new(
            observation_kind,
            crate::ObservationSourceKind::WsRpc,
            endpoint_name.clone(),
            object_key.clone(),
            slot,
            payload.clone(),
        );
        let observation_id_result = self.persistence.record_observation(&observation_input).await;
        let observation_id = match observation_id_result {
            Ok(observation_id) => observation_id,
            Err(error) => return Err(error),
        };
        let should_emit_signal = match notification.method.as_str() {
            "accountNotification" => true,
            "logsNotification" => true,
            "signatureNotification" => true,
            _ => false,
        };
        if should_emit_signal {
            let signal_input = crate::DetectionSignalInput::new(
                build_signal_kind_for_notification(
                    notification.method.as_str(),
                    &notification.params.result,
                ),
                build_signal_severity_for_notification(
                    notification.method.as_str(),
                    &notification.params.result,
                ),
                object_key,
                Some(observation_id),
                None,
                payload,
            );
            let signal_result = self.persistence.record_signal(&signal_input).await;
            if let Err(error) = signal_result {
                return Err(error);
            }
        }
        return Ok(crate::SolanaWsDetectionOutcome::ObservationRecorded { observation_id });
    }

    /// Tries to register a token candidate from one notification.
    async fn try_register_token_candidate(
        &self,
        endpoint_name: std::option::Option<std::string::String>,
        notification: &crate::JsonRpcWsNotification,
    ) -> Result<std::option::Option<crate::DetectionTokenCandidateResult>, crate::Error> {
        if notification.method.as_str() != "programNotification" {
            return Ok(None);
        }
        let result_value = &notification.params.result;
        let pubkey_option = extract_pubkey_from_result(result_value);
        let pubkey = match pubkey_option {
            Some(pubkey) => pubkey,
            None => return Ok(None),
        };
        let account_value_option = extract_account_value_from_result(result_value);
        let account_value = match account_value_option {
            Some(account_value) => account_value,
            None => return Ok(None),
        };
        let parsed_type_option = extract_parsed_account_type(account_value);
        let parsed_type = match parsed_type_option {
            Some(parsed_type) => parsed_type,
            None => return Ok(None),
        };
        let token_program_option = extract_account_owner(account_value);
        let token_program = match token_program_option {
            Some(token_program) => token_program,
            None => return Ok(None),
        };
        if token_program.as_str() != crate::SPL_TOKEN_PROGRAM_ID
            && token_program.as_str() != crate::SPL_TOKEN_2022_PROGRAM_ID
        {
            return Ok(None);
        }
        let decimals = if parsed_type.as_str() == "mint" {
            extract_decimals_from_account_value(account_value)
        } else if parsed_type.as_str() == "account" {
            extract_token_account_decimals_from_account_value(account_value)
        } else {
            None
        };
        let mint = if parsed_type.as_str() == "mint" {
            pubkey.clone()
        } else if parsed_type.as_str() == "account" {
            let mint_option = extract_parsed_account_mint(account_value);
            match mint_option {
                Some(mint) => mint,
                None => return Ok(None),
            }
        } else {
            return Ok(None);
        };
        let slot =
            extract_slot_from_result(notification.method.as_str(), &notification.params.result);
        let payload = build_notification_payload(notification);
        let is_quote_token = mint.as_str() == crate::WSOL_MINT_ID;
        let input = crate::DetectionTokenCandidateInput::new(
            mint,
            None,
            None,
            decimals,
            token_program,
            is_quote_token,
            crate::ObservationSourceKind::WsRpc,
            endpoint_name,
            slot,
            "ws.program_notification".to_string(),
            payload.clone(),
            if parsed_type.as_str() == "mint" {
                "signal.token_mint_account_detected".to_string()
            } else {
                "signal.token_account_detected".to_string()
            },
            crate::AnalysisSignalSeverity::Medium,
            None,
            Some(payload),
        );
        let result = self.persistence.register_token_candidate(&input).await;
        match result {
            Ok(result) => return Ok(Some(result)),
            Err(error) => return Err(error),
        }
    }

    /// Tries to register a pool candidate from one notification.
    async fn try_register_pool_candidate(
        &self,
        endpoint_name: std::option::Option<std::string::String>,
        notification: &crate::JsonRpcWsNotification,
    ) -> Result<std::option::Option<crate::DetectionPoolCandidateResult>, crate::Error> {
        if notification.method.as_str() != "programNotification" {
            return Ok(None);
        }
        let result_value = &notification.params.result;
        let pubkey_option = extract_pubkey_from_result(result_value);
        let pubkey = match pubkey_option {
            Some(pubkey) => pubkey,
            None => return Ok(None),
        };
        let owner_option = extract_program_notification_owner(result_value);
        let owner = match owner_option {
            Some(owner) => owner,
            None => return Ok(None),
        };
        if owner.as_str() == crate::SPL_TOKEN_PROGRAM_ID
            || owner.as_str() == crate::SPL_TOKEN_2022_PROGRAM_ID
        {
            return Ok(None);
        }
        let dexes_result = crate::query_dexs_list(self.persistence.database().as_ref()).await;
        let dexes = match dexes_result {
            Ok(dexes) => dexes,
            Err(error) => return Err(error),
        };
        let mut matched_dex_option: std::option::Option<crate::DexDto> = None;
        for dex in dexes {
            let mut matched = false;
            if let Some(program_id) = &dex.program_id {
                if program_id == &owner {
                    matched = true;
                }
            }
            if !matched {
                if let Some(router_program_id) = &dex.router_program_id {
                    if router_program_id == &owner {
                        matched = true;
                    }
                }
            }
            if matched {
                matched_dex_option = Some(dex);
                break;
            }
        }
        let matched_dex = match matched_dex_option {
            Some(matched_dex) => matched_dex,
            None => return Ok(None),
        };
        let payload = build_notification_payload(notification);
        let slot =
            extract_slot_from_result(notification.method.as_str(), &notification.params.result);
        let signal_kind = format!("signal.pool_candidate.{}", matched_dex.code);
        let input = crate::DetectionPoolCandidateInput::new(
            pubkey,
            owner,
            crate::ObservationSourceKind::WsRpc,
            endpoint_name,
            slot,
            "ws.program_notification".to_string(),
            payload.clone(),
            signal_kind,
            crate::AnalysisSignalSeverity::Medium,
            None,
            Some(payload),
        );
        let result = self.persistence.register_pool_candidate(&input).await;
        match result {
            Ok(result) => return Ok(Some(result)),
            Err(error) => return Err(error),
        }
    }
}

/// Maps one WebSocket notification method to an observation kind.
fn map_notification_method_to_observation_kind(
    method: &str,
) -> std::option::Option<std::string::String> {
    match method {
        "accountNotification" => return Some("ws.account_notification".to_string()),
        "blockNotification" => return Some("ws.block_notification".to_string()),
        "logsNotification" => return Some("ws.logs_notification".to_string()),
        "programNotification" => return Some("ws.program_notification".to_string()),
        "rootNotification" => return Some("ws.root_notification".to_string()),
        "signatureNotification" => return Some("ws.signature_notification".to_string()),
        "slotNotification" => return Some("ws.slot_notification".to_string()),
        "slotsUpdatesNotification" => return Some("ws.slots_updates_notification".to_string()),
        "voteNotification" => return Some("ws.vote_notification".to_string()),
        _ => return None,
    }
}

/// Wraps one raw notification into a normalized JSON payload.
fn build_notification_payload(notification: &crate::JsonRpcWsNotification) -> serde_json::Value {
    return serde_json::json!({
        "jsonrpc": notification.jsonrpc,
        "method": notification.method,
        "subscription": notification.params.subscription,
        "result": notification.params.result,
    });
}

/// Builds one logical object key from the notification result.
fn build_object_key(
    method: &str,
    result: &serde_json::Value,
    subscription: u64,
) -> std::string::String {
    let pubkey_option = extract_pubkey_from_result(result);
    if let Some(pubkey) = pubkey_option {
        return pubkey;
    }
    let signature_option = extract_signature_from_result(result);
    if let Some(signature) = signature_option {
        return signature;
    }
    let slot_option = extract_slot_from_result(method, result);
    if let Some(slot) = slot_option {
        return format!("slot:{slot}");
    }
    return format!("subscription:{subscription}");
}

/// Extracts a slot number from one notification result.
fn extract_slot_from_result(method: &str, result: &serde_json::Value) -> std::option::Option<u64> {
    if method == "rootNotification" {
        if let Some(slot) = result.as_u64() {
            return Some(slot);
        }
    }
    if let Some(slot) = result.get("slot").and_then(serde_json::Value::as_u64) {
        return Some(slot);
    }
    if let Some(context) = result.get("context") {
        if let Some(slot) = context.get("slot").and_then(serde_json::Value::as_u64) {
            return Some(slot);
        }
    }
    if let Some(value) = result.get("value") {
        if let Some(slot) = value.get("slot").and_then(serde_json::Value::as_u64) {
            return Some(slot);
        }
    }
    return None;
}

/// Extracts a pubkey from one notification result.
fn extract_pubkey_from_result(
    result: &serde_json::Value,
) -> std::option::Option<std::string::String> {
    if let Some(pubkey) = result.get("pubkey").and_then(serde_json::Value::as_str) {
        return Some(pubkey.to_string());
    }
    if let Some(value) = result.get("value") {
        if let Some(pubkey) = value.get("pubkey").and_then(serde_json::Value::as_str) {
            return Some(pubkey.to_string());
        }
    }
    return None;
}

/// Extracts a signature from one notification result.
fn extract_signature_from_result(
    result: &serde_json::Value,
) -> std::option::Option<std::string::String> {
    if let Some(signature) = result.get("signature").and_then(serde_json::Value::as_str) {
        return Some(signature.to_string());
    }
    if let Some(value) = result.get("value") {
        if let Some(signature) = value.get("signature").and_then(serde_json::Value::as_str) {
            return Some(signature.to_string());
        }
    }
    return None;
}

/// Extracts one account-like JSON object from one notification result.
fn extract_account_value_from_result(
    result: &serde_json::Value,
) -> std::option::Option<&serde_json::Value> {
    if let Some(account) = result.get("account") {
        return Some(account);
    }
    if let Some(value) = result.get("value") {
        if let Some(account) = value.get("account") {
            return Some(account);
        }
        if value.is_object() {
            return Some(value);
        }
    }
    return None;
}

/// Extracts the parsed account type from one account-like JSON object.
fn extract_parsed_account_type(
    account_value: &serde_json::Value,
) -> std::option::Option<std::string::String> {
    let data_option = account_value.get("data");
    let data = match data_option {
        Some(data) => data,
        None => return None,
    };
    let parsed_option = data.get("parsed");
    let parsed = match parsed_option {
        Some(parsed) => parsed,
        None => return None,
    };
    let type_option = parsed.get("type").and_then(serde_json::Value::as_str);
    match type_option {
        Some(parsed_type) => return Some(parsed_type.to_string()),
        None => return None,
    }
}

/// Extracts the token program owner from one account-like JSON object.
fn extract_account_owner(
    account_value: &serde_json::Value,
) -> std::option::Option<std::string::String> {
    let owner_option = account_value.get("owner").and_then(serde_json::Value::as_str);
    match owner_option {
        Some(owner) => return Some(owner.to_string()),
        None => return None,
    }
}

/// Extracts the decimals value from one parsed mint account-like JSON object.
fn extract_decimals_from_account_value(
    account_value: &serde_json::Value,
) -> std::option::Option<u8> {
    let data_option = account_value.get("data");
    let data = match data_option {
        Some(data) => data,
        None => return None,
    };
    let parsed_option = data.get("parsed");
    let parsed = match parsed_option {
        Some(parsed) => parsed,
        None => return None,
    };
    let info_option = parsed.get("info");
    let info = match info_option {
        Some(info) => info,
        None => return None,
    };
    let decimals_option = info.get("decimals").and_then(serde_json::Value::as_u64);
    let decimals = match decimals_option {
        Some(decimals) => decimals,
        None => return None,
    };
    let converted = u8::try_from(decimals);
    match converted {
        Ok(decimals) => return Some(decimals),
        Err(_) => return None,
    }
}

/// Extracts the owner program id from one program notification result.
fn extract_program_notification_owner(
    result: &serde_json::Value,
) -> std::option::Option<std::string::String> {
    let account_value_option = extract_account_value_from_result(result);
    let account_value = match account_value_option {
        Some(account_value) => account_value,
        None => return None,
    };
    return extract_account_owner(account_value);
}

/// Extracts the parsed token amount decimals from one parsed token account notification.
fn extract_token_account_decimals_from_account_value(
    account_value: &serde_json::Value,
) -> std::option::Option<u8> {
    let data_option = account_value.get("data");
    let data = match data_option {
        Some(data) => data,
        None => return None,
    };
    let parsed_option = data.get("parsed");
    let parsed = match parsed_option {
        Some(parsed) => parsed,
        None => return None,
    };
    let info_option = parsed.get("info");
    let info = match info_option {
        Some(info) => info,
        None => return None,
    };
    let token_amount_option = info.get("tokenAmount");
    let token_amount = match token_amount_option {
        Some(token_amount) => token_amount,
        None => return None,
    };
    let decimals_option = token_amount.get("decimals").and_then(serde_json::Value::as_u64);
    let decimals = match decimals_option {
        Some(decimals) => decimals,
        None => return None,
    };
    let convert_result = u8::try_from(decimals);
    match convert_result {
        Ok(decimals) => return Some(decimals),
        Err(_) => return None,
    }
}

/// Extracts a parsed account mint from one account-like JSON object.
fn extract_parsed_account_mint(
    account_value: &serde_json::Value,
) -> std::option::Option<std::string::String> {
    let data_option = account_value.get("data");
    let data = match data_option {
        Some(data) => data,
        None => return None,
    };
    let parsed_option = data.get("parsed");
    let parsed = match parsed_option {
        Some(parsed) => parsed,
        None => return None,
    };
    let info_option = parsed.get("info");
    let info = match info_option {
        Some(info) => info,
        None => return None,
    };
    let mint_option = info.get("mint").and_then(serde_json::Value::as_str);
    match mint_option {
        Some(mint) => return Some(mint.to_string()),
        None => return None,
    }
}

/// Extracts log lines from one logs notification result.
fn extract_logs_lines(result: &serde_json::Value) -> std::vec::Vec<std::string::String> {
    let mut lines = std::vec::Vec::new();
    let value_option = result.get("value");
    let value = match value_option {
        Some(value) => value,
        None => return lines,
    };
    let logs_option = value.get("logs");
    let logs = match logs_option {
        Some(logs) => logs,
        None => return lines,
    };
    let array_option = logs.as_array();
    let array = match array_option {
        Some(array) => array,
        None => return lines,
    };
    for item in array {
        let line_option = item.as_str();
        if let Some(line) = line_option {
            lines.push(line.to_string());
        }
    }
    return lines;
}

/// Extracts the error field from a signature notification result.
fn extract_signature_notification_err(
    result: &serde_json::Value,
) -> std::option::Option<serde_json::Value> {
    let value_option = result.get("value");
    let value = match value_option {
        Some(value) => value,
        None => return None,
    };
    match value.get("err") {
        Some(err) => return Some(err.clone()),
        None => return None,
    }
}

/// Builds a more specific signal kind for one WebSocket notification.
fn build_signal_kind_for_notification(
    method: &str,
    result: &serde_json::Value,
) -> std::string::String {
    match method {
        "accountNotification" => {
            let account_value_option = extract_account_value_from_result(result);
            if let Some(account_value) = account_value_option {
                let parsed_type_option = extract_parsed_account_type(account_value);
                if let Some(parsed_type) = parsed_type_option {
                    return format!("signal.account_notification.{parsed_type}");
                }
                let owner_option = extract_account_owner(account_value);
                if let Some(owner) = owner_option {
                    if owner.as_str() == crate::SPL_TOKEN_PROGRAM_ID {
                        return "signal.account_notification.spl_token".to_string();
                    }
                    if owner.as_str() == crate::SPL_TOKEN_2022_PROGRAM_ID {
                        return "signal.account_notification.spl_token_2022".to_string();
                    }
                }
            }
            return "signal.account_notification.generic".to_string();
        },
        "logsNotification" => {
            let lines = extract_logs_lines(result);
            for line in &lines {
                if line.contains("Instruction: InitializeMint") {
                    return "signal.logs_notification.initialize_mint".to_string();
                }
                if line.contains("Instruction: MintTo") {
                    return "signal.logs_notification.mint_to".to_string();
                }
                if line.contains("Instruction: Burn") {
                    return "signal.logs_notification.burn".to_string();
                }
                if line.contains("Instruction: InitializeAccount") {
                    return "signal.logs_notification.initialize_account".to_string();
                }
            }
            return "signal.logs_notification.generic".to_string();
        },
        "signatureNotification" => {
            let err_option = extract_signature_notification_err(result);
            match err_option {
                Some(err) => {
                    if err.is_null() {
                        return "signal.signature_notification.confirmed".to_string();
                    } else {
                        return "signal.signature_notification.failed".to_string();
                    }
                },
                None => return "signal.signature_notification.generic".to_string(),
            }
        },
        "programNotification" => {
            let owner_option = extract_program_notification_owner(result);
            let owner = match owner_option {
                Some(owner) => owner,
                None => return "signal.program_notification.generic".to_string(),
            };
            if owner.as_str() == crate::SPL_TOKEN_PROGRAM_ID {
                return "signal.program_notification.spl_token".to_string();
            }
            if owner.as_str() == crate::SPL_TOKEN_2022_PROGRAM_ID {
                return "signal.program_notification.spl_token_2022".to_string();
            }
            return "signal.program_notification.generic".to_string();
        },
        _ => return format!("signal.{}", method.replace("Notification", "").to_lowercase()),
    }
}

/// Builds a more specific severity for one WebSocket notification.
fn build_signal_severity_for_notification(
    method: &str,
    result: &serde_json::Value,
) -> crate::AnalysisSignalSeverity {
    match method {
        "programNotification" => return crate::AnalysisSignalSeverity::Medium,
        "accountNotification" => return crate::AnalysisSignalSeverity::Low,
        "logsNotification" => {
            let lines = extract_logs_lines(result);
            for line in &lines {
                if line.contains("Instruction: InitializeMint") {
                    return crate::AnalysisSignalSeverity::Medium;
                }
            }
            return crate::AnalysisSignalSeverity::Low;
        },
        "signatureNotification" => {
            let err_option = extract_signature_notification_err(result);
            match err_option {
                Some(err) => {
                    if err.is_null() {
                        return crate::AnalysisSignalSeverity::Low;
                    } else {
                        return crate::AnalysisSignalSeverity::Medium;
                    }
                },
                None => return crate::AnalysisSignalSeverity::Low,
            }
        },
        _ => return crate::AnalysisSignalSeverity::Low,
    }
}

#[cfg(test)]
mod tests {
    async fn create_database() -> crate::Database {
        let tempdir = tempfile::tempdir().expect("tempdir must succeed");
        let database_path = tempdir.path().join("detect_ws.sqlite3");
        let config = crate::DatabaseConfig {
            enabled: true,
            backend: crate::DatabaseBackend::Sqlite,
            sqlite: crate::SqliteDatabaseConfig {
                path: database_path.to_string_lossy().to_string(),
                create_if_missing: true,
                busy_timeout_ms: 5000,
                max_connections: 1,
                auto_initialize_schema: true,
                use_wal: true,
            },
        };
        return crate::Database::connect_and_initialize(&config)
            .await
            .expect("database init must succeed");
    }

    fn build_slot_notification() -> crate::JsonRpcWsNotification {
        return crate::JsonRpcWsNotification {
            jsonrpc: "2.0".to_string(),
            method: "slotNotification".to_string(),
            params: crate::JsonRpcWsNotificationParams {
                result: serde_json::json!({
                    "slot": 414726860_u64,
                    "parent": 414726859_u64,
                    "root": 414726828_u64
                }),
                subscription: 1008_u64,
            },
        };
    }

    fn build_program_mint_notification() -> crate::JsonRpcWsNotification {
        return crate::JsonRpcWsNotification {
            jsonrpc: "2.0".to_string(),
            method: "programNotification".to_string(),
            params: crate::JsonRpcWsNotificationParams {
                result: serde_json::json!({
                    "context": {
                        "slot": 777777_u64
                    },
                    "value": {
                        "pubkey": "Mint111111111111111111111111111111111111111",
                        "account": {
                            "owner": crate::SPL_TOKEN_PROGRAM_ID.to_string(),
                            "data": {
                                "program": "spl-token",
                                "parsed": {
                                    "type": "mint",
                                    "info": {
                                        "decimals": 6_u64
                                    }
                                }
                            }
                        }
                    }
                }),
                subscription: 2048_u64,
            },
        };
    }

    fn build_program_pool_candidate_notification() -> crate::JsonRpcWsNotification {
        return crate::JsonRpcWsNotification {
            jsonrpc: "2.0".to_string(),
            method: "programNotification".to_string(),
            params: crate::JsonRpcWsNotificationParams {
                result: serde_json::json!({
                    "context": {
                        "slot": 666666_u64
                    },
                    "value": {
                        "pubkey": "Pool111111111111111111111111111111111111111",
                        "account": {
                            "owner": "DexProgram111111111111111111111111111111111",
                            "data": [
                                "AQID",
                                "base64"
                            ]
                        }
                    }
                }),
                subscription: 5555_u64,
            },
        };
    }

    fn build_logs_notification() -> crate::JsonRpcWsNotification {
        return crate::JsonRpcWsNotification {
            jsonrpc: "2.0".to_string(),
            method: "logsNotification".to_string(),
            params: crate::JsonRpcWsNotificationParams {
                result: serde_json::json!({
                    "context": {
                        "slot": 888888_u64
                    },
                    "value": {
                        "signature": "Sig111111111111111111111111111111111111111111111111",
                        "err": null,
                        "logs": [
                            "Program log: Instruction: InitializeMint"
                        ]
                    }
                }),
                subscription: 3001_u64,
            },
        };
    }

    fn build_signature_notification() -> crate::JsonRpcWsNotification {
        return crate::JsonRpcWsNotification {
            jsonrpc: "2.0".to_string(),
            method: "signatureNotification".to_string(),
            params: crate::JsonRpcWsNotificationParams {
                result: serde_json::json!({
                    "context": {
                        "slot": 999999_u64
                    },
                    "value": {
                        "err": null
                    }
                }),
                subscription: 4001_u64,
            },
        };
    }

    #[tokio::test]
    async fn slot_notification_records_observation() {
        let database = create_database().await;
        let persistence = crate::DetectionPersistenceService::new(std::sync::Arc::new(database));
        let detector = crate::SolanaWsDetectionService::new(persistence);
        let outcome_result = detector
            .process_notification(
                Some("mainnet_public_ws_slots".to_string()),
                &build_slot_notification(),
            )
            .await;
        let outcome = match outcome_result {
            Ok(outcome) => outcome,
            Err(error) => panic!("process_notification failed: {error}"),
        };
        match outcome {
            crate::SolanaWsDetectionOutcome::ObservationRecorded { observation_id } => {
                assert!(observation_id > 0);
            },
            _ => panic!("unexpected detection outcome"),
        }
        let observations_result = crate::query_onchain_observations_list_recent(
            detector.persistence().database().as_ref(),
            10,
        )
        .await;
        let observations = match observations_result {
            Ok(observations) => observations,
            Err(error) => panic!("list_recent_onchain_observations failed: {error}"),
        };
        assert_eq!(observations.len(), 1);
        assert_eq!(observations[0].observation_kind, "ws.slot_notification");
        assert_eq!(observations[0].object_key, "slot:414726860");
    }

    #[tokio::test]
    async fn program_mint_notification_registers_token_candidate() {
        let database = create_database().await;
        let persistence = crate::DetectionPersistenceService::new(std::sync::Arc::new(database));
        let detector = crate::SolanaWsDetectionService::new(persistence);
        let outcome_result = detector
            .process_notification(
                Some("helius_primary_ws_programs".to_string()),
                &build_program_mint_notification(),
            )
            .await;
        let outcome = match outcome_result {
            Ok(outcome) => outcome,
            Err(error) => panic!("process_notification failed: {error}"),
        };
        match outcome {
            crate::SolanaWsDetectionOutcome::TokenCandidateRegistered { result } => {
                assert!(result.token_id > 0);
                assert!(result.observation_id > 0);
                assert!(result.signal_id > 0);
            },
            _ => panic!("unexpected detection outcome"),
        }
        let token_result = crate::query_tokens_get_by_mint(
            detector.persistence().database().as_ref(),
            "Mint111111111111111111111111111111111111111",
        )
        .await;
        let token_option = match token_result {
            Ok(token_option) => token_option,
            Err(error) => panic!("get_token_by_mint failed: {error}"),
        };
        assert!(token_option.is_some());
        let observations_result = crate::query_onchain_observations_list_recent(
            detector.persistence().database().as_ref(),
            10,
        )
        .await;
        let observations = match observations_result {
            Ok(observations) => observations,
            Err(error) => panic!("list_recent_onchain_observations failed: {error}"),
        };
        let signals_result =
            crate::query_analysis_signals_list(detector.persistence().database().as_ref(), 10)
                .await;
        let signals = match signals_result {
            Ok(signals) => signals,
            Err(error) => panic!("list_recent_analysis_signals failed: {error}"),
        };
        assert_eq!(observations.len(), 1);
        assert_eq!(signals.len(), 1);
        assert_eq!(observations[0].object_key, "Mint111111111111111111111111111111111111111");
        assert_eq!(signals[0].object_key, "Mint111111111111111111111111111111111111111");
    }

    #[tokio::test]
    async fn logs_notification_records_observation_and_signal() {
        let database = create_database().await;
        let persistence = crate::DetectionPersistenceService::new(std::sync::Arc::new(database));
        let detector = crate::SolanaWsDetectionService::new(persistence);
        let outcome_result = detector
            .process_notification(
                Some("helius_primary_ws_programs".to_string()),
                &build_logs_notification(),
            )
            .await;
        let outcome = match outcome_result {
            Ok(outcome) => outcome,
            Err(error) => panic!("process_notification failed: {error}"),
        };
        match outcome {
            crate::SolanaWsDetectionOutcome::ObservationRecorded { observation_id } => {
                assert!(observation_id > 0);
            },
            _ => panic!("unexpected detection outcome"),
        }
        let observations_result = crate::query_onchain_observations_list_recent(
            detector.persistence().database().as_ref(),
            10,
        )
        .await;
        let observations = match observations_result {
            Ok(observations) => observations,
            Err(error) => panic!("list_recent_onchain_observations failed: {error}"),
        };
        let signals_result =
            crate::query_analysis_signals_list(detector.persistence().database().as_ref(), 10)
                .await;
        let signals = match signals_result {
            Ok(signals) => signals,
            Err(error) => panic!("list_recent_analysis_signals failed: {error}"),
        };
        assert_eq!(observations.len(), 1);
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].signal_kind, "signal.logs_notification.initialize_mint");
    }

    #[tokio::test]
    async fn signature_notification_records_observation_and_signal() {
        let database = create_database().await;
        let persistence = crate::DetectionPersistenceService::new(std::sync::Arc::new(database));
        let detector = crate::SolanaWsDetectionService::new(persistence);
        let outcome_result = detector
            .process_notification(
                Some("mainnet_public_ws_slots".to_string()),
                &build_signature_notification(),
            )
            .await;
        let outcome = match outcome_result {
            Ok(outcome) => outcome,
            Err(error) => panic!("process_notification failed: {error}"),
        };
        match outcome {
            crate::SolanaWsDetectionOutcome::ObservationRecorded { observation_id } => {
                assert!(observation_id > 0);
            },
            _ => panic!("unexpected detection outcome"),
        }
        let observations_result = crate::query_onchain_observations_list_recent(
            detector.persistence().database().as_ref(),
            10,
        )
        .await;
        let observations = match observations_result {
            Ok(observations) => observations,
            Err(error) => panic!("list_recent_onchain_observations failed: {error}"),
        };
        let signals_result =
            crate::query_analysis_signals_list(detector.persistence().database().as_ref(), 10)
                .await;
        let signals = match signals_result {
            Ok(signals) => signals,
            Err(error) => panic!("list_recent_analysis_signals failed: {error}"),
        };
        assert_eq!(observations.len(), 1);
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].signal_kind, "signal.signature_notification.confirmed");
    }

    #[tokio::test]
    async fn program_pool_candidate_notification_registers_pool_candidate() {
        let database = create_database().await;
        let persistence = crate::DetectionPersistenceService::new(std::sync::Arc::new(database));
        let dex_id = crate::query_dexs_upsert(
            persistence.database().as_ref(),
            &crate::DexDto::new(
                "raydium_amm_v4".to_string(),
                "Raydium AMM v4".to_string(),
                Some("DexProgram111111111111111111111111111111111".to_string()),
                None,
                true,
            ),
        )
        .await
        .expect("upsert dex must succeed");
        assert!(dex_id > 0);
        let detector = crate::SolanaWsDetectionService::new(persistence);
        let outcome_result = detector
            .process_notification(
                Some("helius_primary_ws_programs".to_string()),
                &build_program_pool_candidate_notification(),
            )
            .await;
        let outcome = match outcome_result {
            Ok(outcome) => outcome,
            Err(error) => panic!("process_notification failed: {error}"),
        };
        let pool_id = match outcome {
            crate::SolanaWsDetectionOutcome::PoolCandidateRegistered { result } => {
                assert!(result.dex_id > 0);
                assert!(result.pool_id > 0);
                assert!(result.pool_listing_id > 0);
                result.pool_id
            },
            _ => panic!("unexpected detection outcome"),
        };
        let pool_result = crate::query_pools_get_by_address(
            detector.persistence().database().as_ref(),
            "Pool111111111111111111111111111111111111111",
        )
        .await;
        let pool_option = match pool_result {
            Ok(pool_option) => pool_option,
            Err(error) => panic!("get_pool_by_address failed: {error}"),
        };
        assert!(pool_option.is_some());
        let listing_result = crate::query_pool_listings_get_by_pool_id(
            detector.persistence().database().as_ref(),
            pool_id,
        )
        .await;
        let listing_option = match listing_result {
            Ok(listing_option) => listing_option,
            Err(error) => panic!("get_pool_listing_by_pool_id failed: {error}"),
        };
        assert!(listing_option.is_some());
    }
}
