// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! PostgreSQL event notification target.
//!
//! Persists S3 events into a user-provided PostgreSQL table using the
//! `Target` trait. Two output formats are supported:
//!
//! - `namespace` (default): single row per object key, UPSERT on each event.
//! - `access`: append-only audit log with one row per delivered event.
//!
//! TLS is provided via `tokio-postgres-rustls` with rustls + aws-lc-rs.
//! When `tls_ca` is empty the connector loads native OS trust roots.
//! Connection pooling is delegated to `deadpool-postgres`; the pool itself
//! is `Clone`, so no `Mutex` is required around it.

use crate::plugin::PluginEvent;
use crate::{
    StoreError, Target,
    arn::TargetID,
    error::TargetError,
    runtime::tls::{
        ReloadableTargetTls, TargetTlsGeneration, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode,
        validate_tls_material,
    },
    store::{Key, Store},
    target::{
        ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
        TargetType, build_queued_payload, open_target_queue_store, persist_queued_payload_to_store, redacted_optional_secret,
        redacted_secret, with_delivery_deadline,
    },
};
use async_trait::async_trait;
use deadpool_postgres::{Client as PooledClient, Manager, ManagerConfig, Pool, RecyclingMethod, Runtime, Timeouts};
use rustfs_config::{POSTGRES_DSN_STRING, POSTGRES_TLS_CA, POSTGRES_TLS_CLIENT_CERT, POSTGRES_TLS_CLIENT_KEY};
use rustfs_s3_types::EventName;
use rustfs_tls_runtime::{load_certs, load_private_key};
use std::fmt;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio_postgres::Config;
use tokio_postgres_rustls::MakeRustlsConnect;
use tracing::{info, instrument, warn};
use url::Url;
use uuid::Uuid;

const TARGET_LOG_KEY_FIELD: &str = "Key";

/// Bounds the underlying TCP connect + startup handshake for a new backend
/// connection so an unreachable server cannot block a pool slot forever.
const POSTGRES_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Maximum time `pool.get()` waits for a free slot before returning a timeout.
const POSTGRES_POOL_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
/// Maximum time to create a brand-new pooled connection.
const POSTGRES_POOL_CREATE_TIMEOUT: Duration = Duration::from_secs(15);
/// Maximum time to recycle (health-check) an idle pooled connection.
const POSTGRES_POOL_RECYCLE_TIMEOUT: Duration = Duration::from_secs(10);
/// Absolute ceiling on a single checkout, wrapping `pool.get()` in a Tokio
/// timeout as a belt-and-suspenders guard on top of the deadpool timeouts.
const POSTGRES_POOL_CHECKOUT_HARD_LIMIT: Duration = Duration::from_secs(20);
/// Absolute ceiling for one SQL delivery, including pool checkout and execution.
const POSTGRES_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);

/// Returns `true` for any `s3:ObjectRemoved:*` event.
///
/// Used by the `namespace` format so that object deletions remove the row
/// instead of leaving stale state behind via an UPSERT.
fn is_object_removed_event(event: &EventName) -> bool {
    event.as_str().starts_with("s3:ObjectRemoved")
}

/// Checks out a client from the pool, wrapping `pool.get()` in a Tokio timeout.
///
/// The deadpool wait/create timeouts already bound the checkout, but the outer
/// timeout guarantees a hard ceiling even if a lower layer misbehaves. Any
/// timeout maps to `TargetError::Timeout`, which is a connectivity error so the
/// queue store retains the payload for replay.
async fn checkout_client(pool: &Pool, context: &str) -> Result<PooledClient, TargetError> {
    match tokio::time::timeout(POSTGRES_POOL_CHECKOUT_HARD_LIMIT, pool.get()).await {
        Ok(Ok(client)) => Ok(client),
        Ok(Err(e)) => Err(map_pool_error(e, context)),
        Err(_) => Err(TargetError::Timeout(format!(
            "{context}: pool checkout exceeded {}s hard limit",
            POSTGRES_POOL_CHECKOUT_HARD_LIMIT.as_secs()
        ))),
    }
}

/// Output format selection for the PostgreSQL target.
///
/// - `Namespace`: single-row UPSERT per object key (MinIO `namespace` style).
/// - `Access`: append-only insert per event (audit/compliance use case).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PostgresFormat {
    Namespace,
    Access,
}

impl PostgresFormat {
    pub fn as_str(&self) -> &'static str {
        match self {
            PostgresFormat::Namespace => "namespace",
            PostgresFormat::Access => "access",
        }
    }
}

/// Parses the `format` configuration value.
///
/// Accepts case-insensitive `"namespace"` or `"access"`. Defaults to
/// `Namespace` when the value is missing or empty.
pub fn parse_postgres_format(value: Option<&str>) -> Result<PostgresFormat, TargetError> {
    let raw = value.unwrap_or("").trim();
    if raw.is_empty() {
        return Ok(PostgresFormat::Namespace);
    }
    match raw.to_ascii_lowercase().as_str() {
        "namespace" => Ok(PostgresFormat::Namespace),
        "access" => Ok(PostgresFormat::Access),
        other => Err(TargetError::Configuration(format!(
            "PostgreSQL format must be 'namespace' or 'access', got: {other}"
        ))),
    }
}

/// Parsed representation of a PostgreSQL DSN string.
#[derive(Clone, PartialEq, Eq)]
pub struct PostgresDsn {
    pub host: String,
    pub port: u16,
    pub user: String,
    pub password: Option<String>,
    pub database: String,
    pub schema: String,
}

impl fmt::Debug for PostgresDsn {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("PostgresDsn")
            .field("host", &self.host)
            .field("port", &self.port)
            .field("user", &self.user)
            .field("password", &redacted_optional_secret(self.password.as_deref()))
            .field("database", &self.database)
            .field("schema", &self.schema)
            .finish()
    }
}

impl PostgresDsn {
    /// Parses and validates PostgreSQL DSN string.
    ///
    /// Supports canonical URL format like:
    /// `postgres://user:password@host:5432/database?search_path=public`
    pub fn parse(dsn_string: &str) -> Result<Self, TargetError> {
        let input = dsn_string.trim();
        if input.is_empty() {
            return Err(TargetError::Configuration(format!("PostgreSQL {POSTGRES_DSN_STRING} cannot be empty")));
        }

        let url = Url::parse(input).map_err(|e| TargetError::Configuration(format!("invalid PostgreSQL dsn_string: {e}")))?;
        let scheme = url.scheme().to_ascii_lowercase();
        if scheme != "postgres" && scheme != "postgresql" {
            return Err(TargetError::Configuration(
                "invalid PostgreSQL dsn_string: URL scheme must be postgres or postgresql".to_string(),
            ));
        }

        if url.host_str().is_none() {
            return Err(TargetError::Configuration(
                "invalid PostgreSQL dsn_string: host cannot be empty".to_string(),
            ));
        }

        let user = url.username().trim();
        if user.is_empty() {
            return Err(TargetError::Configuration(
                "invalid PostgreSQL dsn_string: user cannot be empty".to_string(),
            ));
        }

        let host = url.host_str().unwrap_or_default().trim();
        if host.is_empty() {
            return Err(TargetError::Configuration(
                "invalid PostgreSQL dsn_string: host cannot be empty".to_string(),
            ));
        }
        let port = url.port().unwrap_or(5432);

        let database = url.path().trim_start_matches('/').trim();
        if database.is_empty() {
            return Err(TargetError::Configuration(
                "invalid PostgreSQL dsn_string: database cannot be empty".to_string(),
            ));
        }

        let mut schema = "public".to_string();
        for (key, value) in url.query_pairs() {
            if !key.eq_ignore_ascii_case("search_path") {
                return Err(TargetError::Configuration(format!(
                    "invalid PostgreSQL dsn_string: unsupported query parameter '{key}'"
                )));
            }
            let value = value.trim();
            if value.is_empty() {
                return Err(TargetError::Configuration(
                    "invalid PostgreSQL dsn_string: search_path cannot be empty".to_string(),
                ));
            }
            let first_schema = value
                .split(',')
                .next()
                .map(str::trim)
                .filter(|segment| !segment.is_empty())
                .ok_or_else(|| {
                    TargetError::Configuration(
                        "invalid PostgreSQL dsn_string: search_path must contain at least one schema".to_string(),
                    )
                })?;
            validate_pg_identifier(first_schema, "schema")?;
            schema = first_schema.to_string();
        }

        Ok(PostgresDsn {
            host: host.to_string(),
            port,
            user: user.to_string(),
            password: url.password().map(ToOwned::to_owned),
            database: database.to_string(),
            schema,
        })
    }
}

/// Returns a redacted version of the DSN string with the password replaced by
/// `***` while preserving non-secret connection details for diagnostics.
pub(crate) fn redact_postgres_dsn(dsn_string: &str) -> String {
    let input = dsn_string.trim();
    if input.is_empty() {
        return String::new();
    }

    let mut url = match Url::parse(input) {
        Ok(url) => url,
        Err(_) => return "***".to_string(),
    };

    let scheme = url.scheme().to_ascii_lowercase();
    if scheme != "postgres" && scheme != "postgresql" {
        return "***".to_string();
    }

    if url.password().is_some() {
        let _ = url.set_password(Some("***"));
    }

    let mut query_pairs: Vec<(String, String)> = Vec::new();
    let mut has_password_param = false;
    for (key, value) in url.query_pairs() {
        if key.eq_ignore_ascii_case("password") {
            has_password_param = true;
            query_pairs.push((key.into_owned(), "***".to_string()));
        } else {
            query_pairs.push((key.into_owned(), value.into_owned()));
        }
    }
    if has_password_param {
        url.set_query(None);
        let mut serializer = url.query_pairs_mut();
        for (key, value) in query_pairs {
            serializer.append_pair(&key, &value);
        }
    }

    url.to_string()
}

/// Validates a PostgreSQL identifier (schema or table name).
///
/// Accepts only `^[A-Za-z_][A-Za-z0-9_]*$`. Quoted identifiers, dots, and
/// special characters are intentionally rejected to keep SQL string
/// construction safe without runtime escaping.
pub fn validate_pg_identifier(name: &str, kind: &str) -> Result<(), TargetError> {
    if name.is_empty() {
        return Err(TargetError::Configuration(format!("PostgreSQL {kind} cannot be empty")));
    }
    let mut chars = name.chars();
    let Some(first) = chars.next() else {
        return Err(TargetError::Configuration(format!("PostgreSQL {kind} cannot be empty")));
    };
    if !(first.is_ascii_alphabetic() || first == '_') {
        return Err(TargetError::Configuration(format!(
            "PostgreSQL {kind} must start with a letter or underscore"
        )));
    }
    for c in chars {
        if !(c.is_ascii_alphanumeric() || c == '_') {
            return Err(TargetError::Configuration(format!(
                "PostgreSQL {kind} must match ^[A-Za-z_][A-Za-z0-9_]*$"
            )));
        }
    }
    Ok(())
}

/// PostgreSQL target configuration.
///
/// Implements a manual `Debug` that redacts the DSN password to prevent secret
/// leakage through logging or `tracing::instrument` capture.
#[derive(Clone)]
pub struct PostgresArgs {
    pub enable: bool,

    // Connection
    pub dsn_string: String,

    // Schema/Table/Format
    pub schema: String,
    pub table: String,
    pub format: PostgresFormat,

    // TLS
    pub tls_required: bool,
    pub tls_ca: String,
    pub tls_client_cert: String,
    pub tls_client_key: String,

    // Queue
    pub queue_dir: String,
    pub queue_limit: u64,

    pub target_type: TargetType,
}

impl fmt::Debug for PostgresArgs {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("PostgresArgs")
            .field("enable", &self.enable)
            .field("dsn_string", &redact_postgres_dsn(&self.dsn_string))
            .field("schema", &self.schema)
            .field("table", &self.table)
            .field("format", &self.format)
            .field("tls_required", &self.tls_required)
            .field("tls_ca", &self.tls_ca)
            .field("tls_client_cert", &self.tls_client_cert)
            .field("tls_client_key", &redacted_secret(&self.tls_client_key))
            .field("queue_dir", &self.queue_dir)
            .field("queue_limit", &self.queue_limit)
            .field("target_type", &self.target_type)
            .finish()
    }
}

impl PostgresArgs {
    pub fn validate(&self) -> Result<(), TargetError> {
        if !self.enable {
            return Ok(());
        }

        let parsed = PostgresDsn::parse(&self.dsn_string)?;

        if self.schema.trim().is_empty() {
            return Err(TargetError::Configuration("PostgreSQL schema cannot be empty".to_string()));
        }
        validate_pg_identifier(&self.schema, "schema")?;
        if self.schema != parsed.schema {
            return Err(TargetError::Configuration(format!(
                "PostgreSQL schema must match DSN search_path first schema ('{}')",
                parsed.schema
            )));
        }
        validate_pg_identifier(&self.table, "table")?;

        // TLS pair must be both empty or both set
        if self.tls_client_cert.is_empty() != self.tls_client_key.is_empty() {
            return Err(TargetError::Configuration(format!(
                "PostgreSQL {POSTGRES_TLS_CLIENT_CERT} and {POSTGRES_TLS_CLIENT_KEY} must be specified together"
            )));
        }

        // Optional TLS path values must be absolute when present
        if !self.tls_ca.is_empty() && !Path::new(&self.tls_ca).is_absolute() {
            return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CA} must be an absolute path")));
        }
        if !self.tls_client_cert.is_empty() && !Path::new(&self.tls_client_cert).is_absolute() {
            return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CLIENT_CERT} must be an absolute path")));
        }
        if !self.tls_client_key.is_empty() && !Path::new(&self.tls_client_key).is_absolute() {
            return Err(TargetError::Configuration(format!("{POSTGRES_TLS_CLIENT_KEY} must be an absolute path")));
        }

        if !self.queue_dir.is_empty() && !Path::new(&self.queue_dir).is_absolute() {
            return Err(TargetError::Configuration(
                "PostgreSQL queue directory must be an absolute path".to_string(),
            ));
        }

        Ok(())
    }
}

/// Returns the qualified `"schema"."table"` SQL identifier for `args`.
///
/// Both schema and table are pre-validated in `PostgresArgs::validate()` so the
/// values cannot contain quote, dot, or whitespace characters; double-quoting
/// preserves case-sensitivity for users who created their tables with quoted
/// identifiers.
pub fn qualified_table(schema: &str, table: &str) -> String {
    format!(r#""{schema}"."{table}""#)
}

/// SQL for the `namespace` format. Performs UPSERT keyed on the object key.
pub fn namespace_upsert_sql(schema: &str, table: &str) -> String {
    format!(
        "INSERT INTO {} (key, value) VALUES ($1, $2::jsonb) \
         ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
        qualified_table(schema, table)
    )
}

/// SQL for the `namespace` format on object removal. Deletes the row keyed on
/// the object key so the `namespace` table stays consistent with the object
/// lifecycle instead of retaining stale state after a delete.
pub fn namespace_delete_sql(schema: &str, table: &str) -> String {
    format!("DELETE FROM {} WHERE key = $1", qualified_table(schema, table))
}

/// SQL for the `access` format. Append-only with `event_id` as PK so that
/// store-replay scenarios silently skip duplicates while distinct events still
/// land as separate rows.
pub fn access_insert_sql(schema: &str, table: &str) -> String {
    format!(
        "INSERT INTO {} (event_id, event_name, key, value, queued_at_ms) \
         VALUES ($1, $2, $3, $4::jsonb, $5) \
         ON CONFLICT (event_id) DO NOTHING",
        qualified_table(schema, table)
    )
}

/// SQL used by both `init()` and the connectivity probe to verify the table
/// exists and is readable without producing rows or triggering side effects.
pub fn table_probe_sql(schema: &str, table: &str) -> String {
    format!("SELECT 1 FROM {} LIMIT 0", qualified_table(schema, table))
}

/// Builds a rustls `ClientConfig` for the PostgreSQL connection.
///
/// When `tls_ca` is empty the OS native trust store is used via
/// `rustls-native-certs` (0.8 API: `CertificateResult { certs, errors }`).
/// When `tls_client_cert` and `tls_client_key` are both set the connection
/// uses mTLS authentication; otherwise no client cert is sent.
pub fn build_tls_config(args: &PostgresArgs) -> Result<rustls::ClientConfig, TargetError> {
    super::ensure_rustls_provider_installed();

    let mut root_store = rustls::RootCertStore::empty();

    if args.tls_ca.is_empty() {
        let result = rustls_native_certs::load_native_certs();
        if !result.errors.is_empty() {
            warn!(error_count = result.errors.len(), "some native CA certs failed to load");
        }
        if result.certs.is_empty() {
            return Err(TargetError::Configuration(
                "no native CA certs available; specify tls_ca explicitly".to_string(),
            ));
        }
        for cert in result.certs {
            // Skip individual add failures; corrupted certs in the system store
            // shouldn't block the rest from loading.
            let _ = root_store.add(cert);
        }
    } else {
        let certs =
            load_certs(&args.tls_ca).map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CA}: {e}")))?;
        for cert in certs {
            root_store
                .add(cert)
                .map_err(|e| TargetError::Configuration(format!("failed to add CA cert: {e}")))?;
        }
    }

    let builder = rustls::ClientConfig::builder().with_root_certificates(root_store);

    let client_config = if !args.tls_client_cert.is_empty() && !args.tls_client_key.is_empty() {
        let certs = load_certs(&args.tls_client_cert)
            .map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CLIENT_CERT}: {e}")))?;
        let key = load_private_key(&args.tls_client_key)
            .map_err(|e| TargetError::Configuration(format!("invalid {POSTGRES_TLS_CLIENT_KEY}: {e}")))?;

        builder
            .with_client_auth_cert(certs, key)
            .map_err(|e| TargetError::Configuration(format!("invalid mTLS pair: {e}")))?
    } else {
        builder.with_no_client_auth()
    };

    Ok(client_config)
}

/// Builds the deadpool-postgres `Pool` used by the target.
///
/// `args.tls_required` decides whether the connection is plain TCP or wrapped
/// in rustls. The pool is `Clone` and cheap to share across `clone_box`.
pub fn build_pool(args: &PostgresArgs) -> Result<Pool, TargetError> {
    let parsed = PostgresDsn::parse(&args.dsn_string)?;
    let mut pg_config = Config::new();
    pg_config
        .host(&parsed.host)
        .port(parsed.port)
        .user(&parsed.user)
        .dbname(&parsed.database)
        // Bound the TCP connect + startup handshake so an unreachable backend
        // cannot block a pool slot indefinitely.
        .connect_timeout(POSTGRES_CONNECT_TIMEOUT)
        .options(format!("-c search_path={}", parsed.schema));
    if let Some(password) = parsed.password.as_deref()
        && !password.is_empty()
    {
        pg_config.password(password);
    }

    let manager_config = ManagerConfig {
        recycling_method: RecyclingMethod::Fast,
    };

    let manager = if args.tls_required {
        let tls_config = build_tls_config(args)?;
        let connector = MakeRustlsConnect::new(tls_config);
        Manager::from_config(pg_config, connector, manager_config)
    } else {
        Manager::from_config(pg_config, tokio_postgres::NoTls, manager_config)
    };

    // Explicit wait/create/recycle timeouts guarantee that `pool.get()` always
    // returns within a bounded time when the broker/DB is unreachable, instead
    // of blocking the delivery thread forever. A Tokio runtime is required for
    // deadpool to honor these timeouts.
    Pool::builder(manager)
        .runtime(Runtime::Tokio1)
        .timeouts(Timeouts {
            wait: Some(POSTGRES_POOL_WAIT_TIMEOUT),
            create: Some(POSTGRES_POOL_CREATE_TIMEOUT),
            recycle: Some(POSTGRES_POOL_RECYCLE_TIMEOUT),
        })
        .build()
        .map_err(|e| TargetError::Configuration(format!("failed to build PostgreSQL pool: {e}")))
}

/// Classifies a PostgreSQL SQLSTATE code into the proper `TargetError` variant.
///
/// Split out from [`map_pg_error`] so the SQLSTATE-to-variant mapping can be
/// unit-tested without constructing an opaque `tokio_postgres::Error`.
///
/// Classification is by SQLSTATE class (first two characters):
/// - `08` connection exception → `NotConnected` (retry, keep in store).
/// - `28` invalid authorization → `Authentication` (permanent, surfaced).
/// - `23`/`42` integrity/syntax → `Configuration` (permanent, surfaced).
/// - `40` transaction rollback (`40001` serialization_failure,
///   `40P01` deadlock_detected, …) → `Timeout`, a transient/retryable error:
///   the transaction should be retried rather than dropped.
/// - anything else → `Request` (treated as permanent/ambiguous).
fn map_pg_sqlstate(code: &str, detail: &str) -> TargetError {
    match code.get(..2).unwrap_or("") {
        "08" => TargetError::NotConnected,
        "28" => TargetError::Authentication(detail.to_string()),
        "23" | "42" => TargetError::Configuration(detail.to_string()),
        "40" => TargetError::Timeout(detail.to_string()),
        _ => TargetError::Request(detail.to_string()),
    }
}

/// Maps a `tokio_postgres::Error` to the proper `TargetError` variant.
///
/// Connection-class errors (SQLSTATE 08, closed connection, IO) become
/// `NotConnected` so the queue store retains the payload for replay.
/// Schema and constraint problems (SQLSTATE 23, 42) become `Configuration`
/// so they are surfaced to the operator without endless retry.
/// Transaction-rollback errors (SQLSTATE class 40, e.g. serialization failure
/// or deadlock) become `Timeout` so they are retried transiently.
pub fn map_pg_error(err: &tokio_postgres::Error, context: &str) -> TargetError {
    if err.is_closed() {
        return TargetError::NotConnected;
    }
    if let Some(db_err) = err.as_db_error() {
        let detail = format!("{context}: {db_err}");
        return map_pg_sqlstate(db_err.code().code(), &detail);
    }
    TargetError::NotConnected
}

/// Maps a `deadpool_postgres::PoolError` to the proper `TargetError` variant.
pub fn map_pool_error(err: deadpool_postgres::PoolError, context: &str) -> TargetError {
    match err {
        deadpool_postgres::PoolError::Timeout(_) => TargetError::Timeout(format!("{context}: pool timeout")),
        deadpool_postgres::PoolError::Backend(pg_err) => map_pg_error(&pg_err, context),
        deadpool_postgres::PoolError::Closed => TargetError::NotConnected,
        other => TargetError::Request(format!("{context}: {other}")),
    }
}

fn resolve_payload_key(payload: &serde_json::Value, meta: &QueuedPayloadMeta) -> String {
    payload
        .get(TARGET_LOG_KEY_FIELD)
        .and_then(serde_json::Value::as_str)
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| {
            let decoded_object =
                crate::target::decode_object_name(&meta.object_name).unwrap_or_else(|_| meta.object_name.clone());
            format!("{}/{}", meta.bucket_name, decoded_object)
        })
}

/// PostgreSQL notification target.
///
/// Holds a cloneable `deadpool_postgres::Pool` rather than a `Mutex<Option<Pool>>`
/// so that `clone_box` does not duplicate connection state. The optional
/// `QueueStore` provides at-least-once delivery semantics consistent with the
/// other built-in targets.
///
/// When `tls_adapter` is `Some`, the target participates in the
/// coordinated TLS hot-reload system driven by `TlsReloadAdapter`,
/// and the inline fingerprint check in `send_body` is skipped. When `None`,
/// the legacy inline fingerprint check is used as a fallback.
pub struct PostgresTarget<E>
where
    E: PluginEvent,
{
    id: TargetID,
    args: PostgresArgs,
    pool: Arc<parking_lot::Mutex<Pool>>,
    tls_state: Arc<parking_lot::Mutex<super::TargetTlsState>>,
    /// When present, the adapter provides coordinator-managed TLS material;
    /// otherwise the inline fingerprint path is used as a fallback.
    tls_adapter: Option<TlsReloadAdapter<Pool>>,
    namespace_sql: String,
    namespace_delete_sql: String,
    access_sql: String,
    store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
    delivery_counters: Arc<TargetDeliveryCounters>,
    _phantom: std::marker::PhantomData<E>,
}

impl<E> PostgresTarget<E>
where
    E: PluginEvent,
{
    pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
        Box::new(PostgresTarget::<E> {
            id: self.id.clone(),
            args: self.args.clone(),
            pool: Arc::clone(&self.pool),
            tls_state: Arc::clone(&self.tls_state),
            tls_adapter: self.tls_adapter.clone(),
            namespace_sql: self.namespace_sql.clone(),
            namespace_delete_sql: self.namespace_delete_sql.clone(),
            access_sql: self.access_sql.clone(),
            store: self.store.as_ref().map(|s| s.boxed_clone()),
            delivery_counters: Arc::clone(&self.delivery_counters),
            _phantom: std::marker::PhantomData,
        })
    }

    #[instrument(skip(args), fields(target_id_as_string = %id))]
    pub fn new(id: String, args: PostgresArgs) -> Result<Self, TargetError> {
        args.validate()?;
        let target_id = TargetID::new(id, ChannelTargetType::Postgres.as_str().to_string());
        let pool = build_pool(&args)?;

        let queue_store = open_target_queue_store(
            &args.queue_dir,
            args.queue_limit,
            args.target_type,
            ChannelTargetType::Postgres.as_str(),
            &target_id,
            "Failed to open store for PostgreSQL target",
        )?;

        Ok(Self {
            id: target_id,
            namespace_sql: namespace_upsert_sql(&args.schema, &args.table),
            namespace_delete_sql: namespace_delete_sql(&args.schema, &args.table),
            access_sql: access_insert_sql(&args.schema, &args.table),
            args,
            pool: Arc::new(parking_lot::Mutex::new(pool)),
            tls_state: Arc::new(parking_lot::Mutex::new(super::TargetTlsState::default())),
            tls_adapter: None,
            store: queue_store,
            delivery_counters: Arc::new(TargetDeliveryCounters::default()),
            _phantom: std::marker::PhantomData,
        })
    }

    /// Sends a serialized event body to PostgreSQL using the configured format.
    ///
    /// Identifier validation has already happened in `PostgresArgs::validate()`,
    /// so `qualified_table` cannot produce a malformed SQL string here.
    async fn send_body(&self, body: &[u8], event_id: &str, meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
        // When a TLS reload adapter is attached, it drives pool rebuilds in
        // the background. The inline per-send fingerprint check is skipped.
        if self.tls_adapter.is_none() {
            let next_fingerprint =
                super::build_target_tls_fingerprint(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
                    .await?;
            let tls_changed = {
                let tls_state_guard = self.tls_state.lock();
                tls_state_guard.fingerprint.as_ref() != Some(&next_fingerprint)
            };
            if tls_changed {
                let new_pool = build_pool(&self.args)?;
                *self.pool.lock() = new_pool;
                self.tls_state.lock().refresh(next_fingerprint);
            }
        }

        let pool = self.pool.lock().clone();
        let client = checkout_client(&pool, "PostgreSQL pool checkout failed").await?;

        let payload: serde_json::Value =
            serde_json::from_slice(body).map_err(|e| TargetError::Serialization(format!("Failed to parse JSON payload: {e}")))?;

        let key = resolve_payload_key(&payload, meta);

        with_delivery_deadline(POSTGRES_DELIVERY_TIMEOUT, "PostgreSQL delivery", async {
            match self.args.format {
                // For the single-row `namespace` format, an object removal must
                // delete the row rather than UPSERT it, otherwise stale state
                // lingers in the table after the object is gone.
                PostgresFormat::Namespace if is_object_removed_event(&meta.event_name) => {
                    client.execute(&self.namespace_delete_sql, &[&key]).await
                }
                PostgresFormat::Namespace => client.execute(&self.namespace_sql, &[&key, &payload]).await,
                PostgresFormat::Access => {
                    let event_name_str = meta.event_name.to_string();
                    let queued_at_ms = meta.queued_at_unix_ms as i64;
                    client
                        .execute(&self.access_sql, &[&event_id, &event_name_str, &key, &payload, &queued_at_ms])
                        .await
                }
            }
            .map_err(|err| map_pg_error(&err, "PostgreSQL insert failed"))
        })
        .await?;

        self.delivery_counters.record_success();
        Ok(())
    }

    /// Probes the table from `init()`. Failure is non-fatal when a queue is
    /// configured: events buffer in the store until the schema is fixed.
    async fn probe_table(&self) -> Result<(), TargetError> {
        let pool = self.pool.lock().clone();
        let client = checkout_client(&pool, "PostgreSQL pool checkout failed during init probe").await?;
        let sql = table_probe_sql(&self.args.schema, &self.args.table);
        client
            .execute(sql.as_str(), &[])
            .await
            .map_err(|e| map_pg_error(&e, "PostgreSQL table probe failed"))?;
        Ok(())
    }
}

#[async_trait]
impl<E> ReloadableTargetTls for PostgresTarget<E>
where
    E: PluginEvent,
{
    type Material = Pool;

    fn tls_input_set(&self) -> TargetTlsInputSet {
        TargetTlsInputSet {
            ca_path: self.args.tls_ca.clone(),
            client_cert_path: self.args.tls_client_cert.clone(),
            client_key_path: self.args.tls_client_key.clone(),
            target_label: format!("postgres:{}", self.id.id),
        }
    }

    async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
        build_pool(&self.args)
    }

    async fn apply_tls_material(
        &self,
        _generation: TargetTlsGeneration,
        material: Arc<Self::Material>,
        _mode: ReloadApplyMode,
    ) -> Result<(), TargetError> {
        *self.pool.lock() = (*material).clone();
        Ok(())
    }

    async fn validate_tls_files(&self) -> Result<(), TargetError> {
        validate_tls_material(&self.args.tls_ca, &self.args.tls_client_cert, &self.args.tls_client_key)
    }
}

#[async_trait]
impl<E> Target<E> for PostgresTarget<E>
where
    E: PluginEvent,
{
    fn id(&self) -> TargetID {
        self.id.clone()
    }

    async fn is_active(&self) -> Result<bool, TargetError> {
        if !self.is_enabled() {
            return Ok(false);
        }

        match tokio::time::timeout(Duration::from_secs(10), async {
            let pool = self.pool.lock().clone();
            let client = checkout_client(&pool, "PostgreSQL pool checkout failed").await?;
            client
                .execute("SELECT 1", &[])
                .await
                .map_err(|e| map_pg_error(&e, "PostgreSQL liveness probe failed"))?;
            Ok::<(), TargetError>(())
        })
        .await
        {
            Ok(Ok(())) => Ok(true),
            Ok(Err(err)) => Err(err),
            Err(_) => Err(TargetError::Timeout("PostgreSQL liveness probe timed out after 10s".to_string())),
        }
    }

    async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
        let queued = match build_queued_payload(event.as_ref()) {
            Ok(queued) => queued,
            Err(err) => {
                self.delivery_counters.record_final_failure();
                return Err(err);
            }
        };

        if let Some(store) = &self.store {
            if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
                self.delivery_counters.record_final_failure();
                return Err(e);
            }
            Ok(())
        } else {
            // No queue: deliver immediately. Fresh UUID acts as the access-format
            // event_id so retries from the caller produce distinct rows.
            let event_id = Uuid::new_v4().to_string();
            if let Err(err) = self.send_body(&queued.body, &event_id, &queued.meta).await {
                self.delivery_counters.record_final_failure();
                return Err(err);
            }
            Ok(())
        }
    }

    async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError> {
        // Use the store key as a stable event_id so replays of the same physical
        // event are idempotent under the access-format composite PK.
        let event_id = key.to_string();
        self.send_body(&body, &event_id, &meta).await
    }

    async fn close(&self) -> Result<(), TargetError> {
        self.pool.lock().close();
        // Adapter cleanup is done by the coordinator; no local state to reset.
        info!(target_id = %self.id, "PostgreSQL target closed");
        Ok(())
    }

    fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
        self.store.as_deref()
    }

    fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
        self.clone_box()
    }

    async fn init(&self) -> Result<(), TargetError> {
        if !self.is_enabled() {
            return Ok(());
        }
        match self.probe_table().await {
            Ok(()) => Ok(()),
            Err(err) if self.store.is_some() => {
                warn!(target_id = %self.id, error = %err, "PostgreSQL init probe failed; events will buffer in store");
                Ok(())
            }
            Err(err) => Err(err),
        }
    }

    fn is_enabled(&self) -> bool {
        self.args.enable
    }

    fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
        self.delivery_counters.snapshot(
            self.store.as_deref().map_or(0, |store| store.len() as u64),
            // Postgres targets record no terminal failures and keep no failed store.
            0,
        )
    }

    fn record_final_failure(&self) {
        self.delivery_counters.record_final_failure();
    }
}

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

    fn base_args() -> PostgresArgs {
        PostgresArgs {
            enable: true,
            dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public".to_string(),
            schema: "public".to_string(),
            table: "rustfs_events_namespace".to_string(),
            format: PostgresFormat::Namespace,
            tls_required: false,
            tls_ca: String::new(),
            tls_client_cert: String::new(),
            tls_client_key: String::new(),
            queue_dir: String::new(),
            queue_limit: 100_000,
            target_type: TargetType::NotifyEvent,
        }
    }

    #[test]
    fn validate_disabled_skips_all_checks() {
        let args = PostgresArgs {
            enable: false,
            dsn_string: String::new(),
            schema: String::new(),
            table: String::new(),
            ..base_args()
        };
        assert!(args.validate().is_ok());
    }

    #[test]
    fn validate_accepts_base_args() {
        assert!(base_args().validate().is_ok());
    }

    #[tokio::test]
    async fn is_active_returns_false_when_disabled() {
        let target = PostgresTarget::<String>::new(
            "postgres:test".to_string(),
            PostgresArgs {
                enable: false,
                dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public".to_string(),
                ..base_args()
            },
        )
        .expect("disabled target should still construct");

        assert!(!target.is_active().await.expect("disabled target should not probe"));
    }

    #[test]
    fn validate_rejects_empty_dsn_string() {
        let args = PostgresArgs {
            dsn_string: String::new(),
            ..base_args()
        };
        let err = args.validate().expect_err("empty dsn string should fail");
        assert!(err.to_string().contains("dsn_string cannot be empty"));
    }

    #[test]
    fn validate_rejects_invalid_dsn_string() {
        let args = PostgresArgs {
            dsn_string: "postgres://".to_string(),
            ..base_args()
        };
        let err = args.validate().expect_err("invalid dsn should fail");
        assert!(err.to_string().contains("invalid PostgreSQL dsn_string"));
    }

    #[test]
    fn validate_rejects_invalid_schema_identifier() {
        let args = PostgresArgs {
            schema: "public; DROP TABLE".to_string(),
            ..base_args()
        };
        let err = args.validate().expect_err("invalid schema should fail");
        assert!(err.to_string().contains("schema"));
    }

    #[test]
    fn validate_rejects_invalid_table_identifier() {
        let args = PostgresArgs {
            table: "events;".to_string(),
            ..base_args()
        };
        let err = args.validate().expect_err("invalid table should fail");
        assert!(err.to_string().contains("table"));
    }

    #[test]
    fn validate_rejects_table_starting_with_digit() {
        let args = PostgresArgs {
            table: "1events".to_string(),
            ..base_args()
        };
        let err = args.validate().expect_err("digit-leading table should fail");
        assert!(err.to_string().contains("table"));
    }

    #[test]
    fn validate_rejects_mtls_without_key() {
        let args = PostgresArgs {
            tls_client_cert: "/etc/ssl/client.pem".to_string(),
            tls_client_key: String::new(),
            ..base_args()
        };
        let err = args.validate().expect_err("missing key should fail");
        assert!(err.to_string().contains("must be specified together"));
    }

    #[test]
    fn validate_rejects_relative_queue_dir() {
        let args = PostgresArgs {
            queue_dir: "relative/path".to_string(),
            ..base_args()
        };
        let err = args.validate().expect_err("relative queue_dir should fail");
        assert!(err.to_string().contains("absolute path"));
    }

    #[test]
    fn validate_rejects_relative_tls_ca() {
        let args = PostgresArgs {
            tls_ca: "ca.pem".to_string(),
            ..base_args()
        };
        let err = args.validate().expect_err("relative tls_ca should fail");
        assert!(err.to_string().contains("absolute path"));
    }

    #[test]
    fn parse_format_defaults_to_namespace() {
        assert_eq!(parse_postgres_format(None).expect("ok"), PostgresFormat::Namespace);
        assert_eq!(parse_postgres_format(Some("")).expect("ok"), PostgresFormat::Namespace);
        assert_eq!(parse_postgres_format(Some("  ")).expect("ok"), PostgresFormat::Namespace);
    }

    #[test]
    fn parse_format_accepts_variants() {
        assert_eq!(parse_postgres_format(Some("namespace")).expect("ok"), PostgresFormat::Namespace);
        assert_eq!(parse_postgres_format(Some("ACCESS")).expect("ok"), PostgresFormat::Access);
        assert_eq!(parse_postgres_format(Some("Access")).expect("ok"), PostgresFormat::Access);
    }

    #[test]
    fn parse_format_rejects_unknown() {
        let err = parse_postgres_format(Some("structured")).expect_err("unknown format should fail");
        assert!(err.to_string().contains("must be 'namespace' or 'access'"));
    }

    #[test]
    fn parse_dsn_extracts_search_path_schema() {
        let parsed = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events?search_path=audit,public")
            .expect("dsn should parse");
        assert_eq!(parsed.host, "localhost");
        assert_eq!(parsed.port, 5432);
        assert_eq!(parsed.user, "postgres");
        assert_eq!(parsed.password.as_deref(), Some("secret"));
        assert_eq!(parsed.database, "rustfs_events");
        assert_eq!(parsed.schema, "audit");
    }

    #[test]
    fn parse_dsn_defaults_schema_to_public() {
        let parsed = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events").expect("dsn should parse");
        assert_eq!(parsed.schema, "public");
    }

    #[test]
    fn parse_dsn_rejects_invalid_scheme() {
        let err = PostgresDsn::parse("mysql://user:pass@localhost:5432/db").expect_err("scheme should fail");
        assert!(err.to_string().contains("scheme must be postgres or postgresql"));
    }

    #[test]
    fn parse_dsn_rejects_invalid_search_path_identifier() {
        let err = PostgresDsn::parse("postgres://postgres:secret@localhost:5432/rustfs_events?search_path=public;drop")
            .expect_err("invalid search_path should fail");
        assert!(err.to_string().contains("schema"));
    }

    #[test]
    fn validate_rejects_schema_mismatch_with_dsn_search_path() {
        let args = PostgresArgs {
            schema: "public".to_string(),
            dsn_string: "postgres://postgres:secret@localhost:5432/rustfs_events?search_path=audit".to_string(),
            ..base_args()
        };
        let err = args.validate().expect_err("schema mismatch should fail");
        assert!(err.to_string().contains("schema must match DSN search_path"));
    }

    #[test]
    fn debug_masks_password() {
        let args = base_args();
        let rendered = format!("{args:?}");
        assert!(!rendered.contains("secret"), "password leaked: {rendered}");
        assert!(rendered.contains("postgres:***@"));
    }

    #[test]
    fn debug_masks_password_when_empty_shows_blank() {
        let args = PostgresArgs {
            dsn_string: "postgres://postgres@localhost:5432/rustfs_events?search_path=public".to_string(),
            ..base_args()
        };
        let rendered = format!("{args:?}");
        assert!(!rendered.contains(":***@"));
    }

    #[test]
    fn debug_redacts_postgres_dsn_password() {
        let dsn = PostgresDsn::parse("postgres://postgres:pg-secret@localhost:5432/rustfs_events?search_path=public")
            .expect("valid DSN");

        let rendered = format!("{dsn:?}");

        assert!(!rendered.contains("pg-secret"));
        assert!(rendered.contains(REDACTED_SECRET));
        assert!(rendered.contains("rustfs_events"));
    }

    #[test]
    fn redact_postgres_dsn_masks_password_query_parameter() {
        let redacted = redact_postgres_dsn("postgres://postgres@localhost:5432/db?search_path=public&password=secret");
        assert!(!redacted.contains("secret"));
        assert!(redacted.contains("password=%2A%2A%2A") || redacted.contains("password=***"));
    }

    #[test]
    fn qualified_table_double_quotes_both_parts() {
        assert_eq!(qualified_table("public", "events"), r#""public"."events""#);
        assert_eq!(qualified_table("audit", "rustfs_events"), r#""audit"."rustfs_events""#);
    }

    #[test]
    fn namespace_upsert_uses_on_conflict_update() {
        let sql = namespace_upsert_sql("public", "events");
        assert!(sql.contains("ON CONFLICT (key) DO UPDATE"));
        assert!(sql.contains(r#""public"."events""#));
        assert!(sql.contains("$2::jsonb"));
    }

    #[test]
    fn access_insert_uses_event_id_pk_with_on_conflict_do_nothing() {
        let sql = access_insert_sql("public", "events_access");
        assert!(sql.contains("event_id"));
        assert!(sql.contains("ON CONFLICT (event_id) DO NOTHING"));
        assert!(sql.contains(r#""public"."events_access""#));
        assert!(sql.contains("$4::jsonb"));
    }

    #[test]
    fn namespace_delete_targets_row_by_key() {
        let sql = namespace_delete_sql("public", "events");
        assert!(sql.starts_with("DELETE FROM"));
        assert!(sql.contains(r#""public"."events""#));
        assert!(sql.contains("WHERE key = $1"));
    }

    #[test]
    fn is_object_removed_event_matches_all_removed_variants() {
        assert!(is_object_removed_event(&EventName::ObjectRemovedDelete));
        assert!(is_object_removed_event(&EventName::ObjectRemovedDeleteMarkerCreated));
        assert!(is_object_removed_event(&EventName::ObjectRemovedDeleteAllVersions));
        assert!(is_object_removed_event(&EventName::ObjectRemovedAll));
        assert!(!is_object_removed_event(&EventName::ObjectCreatedPut));
        assert!(!is_object_removed_event(&EventName::ObjectAccessedGet));
    }

    #[test]
    fn map_pg_sqlstate_classifies_transaction_rollback_as_transient() {
        // 40001 serialization_failure and 40P01 deadlock_detected are transient
        // and must be retried, not dropped as permanent failures.
        assert!(matches!(map_pg_sqlstate("40001", "ctx: serialization"), TargetError::Timeout(_)));
        assert!(matches!(map_pg_sqlstate("40P01", "ctx: deadlock"), TargetError::Timeout(_)));
        assert!(matches!(map_pg_sqlstate("40000", "ctx: rollback"), TargetError::Timeout(_)));
    }

    #[test]
    fn map_pg_sqlstate_classifies_connection_and_permanent_errors() {
        assert!(matches!(map_pg_sqlstate("08006", "ctx"), TargetError::NotConnected));
        assert!(matches!(map_pg_sqlstate("08001", "ctx"), TargetError::NotConnected));
        assert!(matches!(map_pg_sqlstate("28P01", "ctx: auth"), TargetError::Authentication(_)));
        assert!(matches!(map_pg_sqlstate("23505", "ctx: unique"), TargetError::Configuration(_)));
        assert!(matches!(map_pg_sqlstate("42P01", "ctx: undefined_table"), TargetError::Configuration(_)));
        // Unknown class stays permanent (ambiguous → surfaced as Request).
        assert!(matches!(map_pg_sqlstate("22001", "ctx: data"), TargetError::Request(_)));
        assert!(matches!(map_pg_sqlstate("", "ctx: empty"), TargetError::Request(_)));
    }

    #[test]
    fn transient_pg_errors_are_connectivity_errors() {
        // Transaction-rollback errors must be treated as connectivity errors so
        // the queue store retains the payload for replay instead of dropping it.
        assert!(crate::target::is_connectivity_error(&map_pg_sqlstate("40001", "ctx")));
        assert!(crate::target::is_connectivity_error(&map_pg_sqlstate("40P01", "ctx")));
    }

    #[test]
    fn table_probe_does_not_select_rows() {
        let sql = table_probe_sql("public", "events");
        assert!(sql.contains("LIMIT 0"));
        assert!(sql.contains(r#""public"."events""#));
    }

    #[test]
    fn validate_pg_identifier_accepts_alphanumerics() {
        assert!(validate_pg_identifier("events", "table").is_ok());
        assert!(validate_pg_identifier("rustfs_events_v2", "table").is_ok());
        assert!(validate_pg_identifier("_underscored", "table").is_ok());
    }

    #[test]
    fn validate_pg_identifier_rejects_dot_and_quote() {
        assert!(validate_pg_identifier("public.events", "table").is_err());
        assert!(validate_pg_identifier("events\"DROP", "table").is_err());
        assert!(validate_pg_identifier("a b", "table").is_err());
    }

    #[test]
    fn resolve_payload_key_prefers_serialized_key_field() {
        let payload = serde_json::json!({
            "EventName": "s3:ObjectCreated:Put",
            "Key": "bucket-a/folder/object.txt",
            "Records": []
        });
        let meta = QueuedPayloadMeta::new(
            rustfs_s3_types::EventName::ObjectCreatedPut,
            "bucket-a".to_string(),
            "fallback%2Fvalue.txt".to_string(),
            "application/json",
            0,
        );

        assert_eq!(resolve_payload_key(&payload, &meta), "bucket-a/folder/object.txt");
    }

    #[test]
    fn resolve_payload_key_falls_back_to_decoded_meta_key() {
        let payload = serde_json::json!({
            "EventName": "s3:ObjectCreated:Put",
            "Records": []
        });
        let meta = QueuedPayloadMeta::new(
            rustfs_s3_types::EventName::ObjectCreatedPut,
            "bucket-a".to_string(),
            "hello+world%2Ftest.txt".to_string(),
            "application/json",
            0,
        );

        assert_eq!(resolve_payload_key(&payload, &meta), "bucket-a/hello world/test.txt");
    }
}
