// 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.

//! Regression guards for the NATS JetStream publish feature.
//!
//! The JetStream surface can be removed and the build still succeed unless something fails when it
//! disappears. These layered guards make a silent removal or a silent classification drift a build or
//! test failure:
//!
//! - a compile-time reference to the JetStream config surface and the target entry point,
//! - a module-presence test naming the public entry type and the publish-error variant,
//! - completeness tests over the publish-error-class enums with no wildcard arm,
//! - a server-backed end-to-end publish-and-ack test, ignored by default so the offline gate stays
//!   green without a NATS server.
//!
//! The end-to-end guard requires a running NATS server with JetStream enabled. To run it locally:
//!
//!     docker run -d --name rustfs-nats-test -p 4222:4222 nats:2 -js
//!     cargo test -p rustfs-targets --test nats_jetstream_regression_guards -- --ignored
//!
//! Override the server URL with RUSTFS_TEST_NATS_URL.

use async_nats::jetstream::context::PublishErrorKind;
use rustfs_targets::check_nats_server_available;
use rustfs_targets::error::TargetError;
use rustfs_targets::target::nats::{NATSArgs, NATSTarget};
use rustfs_targets::target::{FailedErrorClass, TargetType};

// Compile-time module-presence assertion.
//
// A const initializer names the JetStream config fields on the public target args type and the public
// target constructor by path, referencing the JetStream wiring at compile time without running
// anything. Removing the jetstream_enable, jetstream_stream_name, or jetstream_ack_timeout_secs fields
// from NATSArgs, or removing the NATSTarget constructor, while the config flag keys remain, fails to
// compile here. A static reference rather than a runtime check, so it fires at build time even when no
// test is run.
const _: fn(&NATSArgs) = |args: &NATSArgs| {
    // Each field is read by name, so removing any of the three from NATSArgs fails to compile here.
    let _enable: Option<bool> = args.jetstream_enable;
    let _stream_name: &Option<String> = &args.jetstream_stream_name;
    let _ack_timeout: Option<u64> = args.jetstream_ack_timeout_secs;

    // The publish target entry point. Naming it by path ties this assertion to the constructor that
    // wires the JetStream context, so deleting the entry point breaks the build.
    let _entry: fn(String, NATSArgs) -> Result<NATSTarget<String>, TargetError> = NATSTarget::<String>::new;
};

/// Module-presence test naming the public JetStream entry type, the health-check entry, and the
/// publish-error variant by path. Deleting the NATSTarget constructor, the check_nats_server_available
/// entry, or the JetStreamPublish error variant fails to compile this test.
#[test]
fn jetstream_public_entry_points_are_present() {
    let _entry: fn(String, NATSArgs) -> Result<NATSTarget<String>, TargetError> = NATSTarget::<String>::new;

    let _health_check = check_nats_server_available;

    // The publish path classifies a publish failure into this variant. Constructing it by name binds
    // the guard to the variant, so removing it from TargetError breaks the build.
    let publish_error = TargetError::JetStreamPublish {
        retryable: true,
        detail: "guard".to_string(),
    };
    match publish_error {
        TargetError::JetStreamPublish { retryable, detail } => {
            assert!(retryable);
            assert_eq!(detail, "guard");
        }
        other => panic!("expected the JetStream publish variant, found {other:?}"),
    }
}

/// Cross-module completeness over the publish-error class.
///
/// Lists every async-nats PublishErrorKind variant by name with no wildcard arm. The publish path
/// classifies each kind into retryable or terminal. A new variant added upstream, or a renamed or
/// removed one, fails to compile this match, forcing the classification to be revisited rather than
/// silently folding the new kind into a catch-all.
#[test]
fn publish_error_kind_is_exhaustively_enumerated() {
    fn name_of(kind: PublishErrorKind) -> &'static str {
        match kind {
            PublishErrorKind::StreamNotFound => "StreamNotFound",
            PublishErrorKind::WrongLastMessageId => "WrongLastMessageId",
            PublishErrorKind::WrongLastSequence => "WrongLastSequence",
            PublishErrorKind::TimedOut => "TimedOut",
            PublishErrorKind::BrokenPipe => "BrokenPipe",
            PublishErrorKind::MaxAckPending => "MaxAckPending",
            PublishErrorKind::MaxPayloadExceeded => "MaxPayloadExceeded",
            PublishErrorKind::Other => "Other",
        }
    }

    let all = [
        PublishErrorKind::StreamNotFound,
        PublishErrorKind::WrongLastMessageId,
        PublishErrorKind::WrongLastSequence,
        PublishErrorKind::TimedOut,
        PublishErrorKind::BrokenPipe,
        PublishErrorKind::MaxAckPending,
        PublishErrorKind::MaxPayloadExceeded,
        PublishErrorKind::Other,
    ];
    assert_eq!(
        all.len(),
        8,
        "the publish-error class has eight kinds, update the classification on a change"
    );
    for kind in all {
        assert!(!name_of(kind).is_empty());
    }
}

/// Cross-module completeness over the failed-store error class.
///
/// Lists every FailedErrorClass variant by name with no wildcard arm. The replay worker tags a failed
/// entry with one of these classes. Adding or removing a class fails to compile this match, so the
/// tagging and the operator-facing tag stay in step.
#[test]
fn failed_error_class_is_exhaustively_enumerated() {
    fn tag_of(class: FailedErrorClass) -> &'static str {
        match class {
            FailedErrorClass::Terminal => "terminal",
        }
    }

    let all = [FailedErrorClass::Terminal];
    assert_eq!(
        all.len(),
        1,
        "the failed-store error class has one variant, update the tagging on a change"
    );
    for class in all {
        assert_eq!(tag_of(class), class.as_str(), "the guard tag matches the operator-facing tag");
    }
}

fn server_url() -> String {
    std::env::var("RUSTFS_TEST_NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string())
}

fn jetstream_args(subject: &str, stream_name: &str, queue_dir: &str) -> NATSArgs {
    NATSArgs {
        enable: true,
        address: server_url(),
        subject: subject.to_string(),
        username: String::new(),
        password: String::new(),
        token: String::new(),
        credentials_file: String::new(),
        tls_ca: String::new(),
        tls_client_cert: String::new(),
        tls_client_key: String::new(),
        tls_required: false,
        queue_dir: queue_dir.to_string(),
        queue_limit: 0,
        jetstream_enable: Some(true),
        jetstream_stream_name: Some(stream_name.to_string()),
        jetstream_ack_timeout_secs: Some(30),
        target_type: TargetType::NotifyEvent,
    }
}

/// Server-backed end-to-end publish-and-ack guard.
///
/// Provisions a writable stream as an operator would, then drives the real target publish path with
/// the JetStream flag on: init connects and validates the stream, save persists the event to the
/// on-disk queue, send_from_store publishes and awaits the real PublishAck and clears the entry on it.
/// The stream message count rising and the queue draining proves the message was published and
/// acknowledged through the production code, the user-visible signal a build-only check cannot see.
///
/// Ignored by default because it needs a running NATS server with JetStream.
#[tokio::test]
#[ignore = "requires a live NATS JetStream server (see module docs; RUSTFS_TEST_NATS_URL overrides)"]
async fn end_to_end_publish_is_acked_on_the_stream() {
    use rustfs_targets::EventName;
    use rustfs_targets::Target;
    use rustfs_targets::target::EntityTarget;
    use std::sync::Arc;
    use uuid::Uuid;

    let suffix = Uuid::new_v4().simple().to_string();
    let stream_name = format!("RUSTFS_TEST_{suffix}");
    let subject = format!("rustfs.guard.{suffix}");
    let queue_dir = std::env::temp_dir().join(format!("rustfs-nats-guard-{suffix}"));
    let queue_dir = queue_dir.to_string_lossy().to_string();

    // Provision the stream as the operator would. RustFS validates it, never creates it.
    let client = async_nats::connect(server_url()).await.expect("connect to NATS");
    let context = async_nats::jetstream::new(client);
    let _ = context.delete_stream(&stream_name).await;
    context
        .create_stream(async_nats::jetstream::stream::Config {
            name: stream_name.clone(),
            subjects: vec![subject.clone()],
            duplicate_window: std::time::Duration::from_secs(300),
            ..Default::default()
        })
        .await
        .expect("provision the stream");

    let args = jetstream_args(&subject, &stream_name, &queue_dir);
    let target = NATSTarget::<String>::new("guard-target".to_string(), args).expect("build the flag-on target");

    target
        .init()
        .await
        .expect("init connects and validates the operator-provisioned stream");

    let count_before = stream_message_count(&context, &stream_name).await;

    let event = EntityTarget {
        object_name: "guard/object.txt".to_string(),
        bucket_name: "guard-bucket".to_string(),
        event_name: EventName::ObjectCreatedPut,
        data: "guard-payload".to_string(),
    };
    target
        .save(Arc::new(event))
        .await
        .expect("save persists the event to the queue");

    let keys = target.store().expect("the flag-on target has a queue store").list();
    assert_eq!(keys.len(), 1, "the saved event is queued exactly once");
    target
        .send_from_store(keys[0].clone())
        .await
        .expect("the queued event publishes and is acked");

    let count_after = stream_message_count(&context, &stream_name).await;
    assert_eq!(
        count_after,
        count_before + 1,
        "the published event is durably on the stream after its ack"
    );

    let remaining = target.store().expect("the flag-on target has a queue store").list();
    assert!(remaining.is_empty(), "the queue entry is cleared on the ack");

    target.close().await.expect("close drains the target");
    let _ = context.delete_stream(&stream_name).await;
    let _ = std::fs::remove_dir_all(&queue_dir);
}

async fn stream_message_count(context: &async_nats::jetstream::Context, stream_name: &str) -> u64 {
    let mut stream = context.get_stream(stream_name).await.expect("look up the stream");
    let info = stream.info().await.expect("read the stream info");
    info.state.messages
}
