mod common;

pub use crate::common::*;
use byte_strings::c_str;
use indymilter::{
    message::{
        command::{Command, ConnInfoPayload, MacroPayload, OptNegPayload},
        reply::Reply,
        PROTOCOL_VERSION,
    },
    Actions, Callbacks, Config, MacroStage, ProtoOpts, SocketInfo, Status,
};
use std::{
    collections::HashMap,
    io::ErrorKind,
    net::Ipv4Addr,
    sync::{Arc, Mutex},
};

#[tokio::test]
async fn default_opts() {
    init_tracing_subscriber();

    let callbacks = Callbacks::<()>::new()
        .on_eoh(|_| Box::pin(async { Status::Continue }));

    let milter = Milter::spawn(LOCALHOST, callbacks, default_config())
        .await
        .unwrap();

    let mut client = Client::connect(milter.addr()).await.unwrap();

    client
        .write_command(Command::OptNeg(OptNegPayload {
            version: PROTOCOL_VERSION,
            actions: Actions::all(),
            opts: ProtoOpts::all(),
        }))
        .await
        .unwrap();

    let reply = client.read_reply().await.unwrap();
    assert_eq!(
        reply,
        Reply::OptNeg {
            version: PROTOCOL_VERSION,
            actions: Actions::empty(),
            opts: ProtoOpts::NO_CONNECT
                | ProtoOpts::NO_HELO
                | ProtoOpts::NO_MAIL
                | ProtoOpts::NO_RCPT
                | ProtoOpts::NO_DATA
                | ProtoOpts::NO_HEADER
                // no `ProtoOpts::NO_EOH`
                | ProtoOpts::NO_BODY
                | ProtoOpts::NO_UNKNOWN,
            macros: HashMap::new(),
        }
    );

    client.write_command(Command::Quit).await.unwrap();

    client.disconnect().await.unwrap();

    milter.shutdown().await.unwrap();
}

#[tokio::test]
async fn request_macros() {
    init_tracing_subscriber();

    let callbacks = Callbacks::<()>::new()
        .on_negotiate(|cx, _, _| {
            Box::pin(async {
                cx.requested_actions = Actions::empty();
                cx.requested_opts = ProtoOpts::empty();
                cx.requested_macros = HashMap::from([(MacroStage::Helo, c_str!("{what}").into())]);

                Status::Continue
            })
        });

    let config = Config {
        actions: Actions::QUARANTINE,  // overridden in negotiate
        ..default_config()
    };

    let milter = Milter::spawn(LOCALHOST, callbacks, config).await.unwrap();

    let mut client = Client::connect(milter.addr()).await.unwrap();

    client
        .write_command(Command::OptNeg(OptNegPayload {
            version: PROTOCOL_VERSION,
            actions: Actions::all(),
            opts: ProtoOpts::all(),
        }))
        .await
        .unwrap();

    let reply = client.read_reply().await.unwrap();
    assert_eq!(
        reply,
        Reply::OptNeg {
            version: PROTOCOL_VERSION,
            actions: Actions::empty(),
            opts: ProtoOpts::empty(),
            macros: HashMap::from([(MacroStage::Helo, c_str!("{what}").into())]),
        }
    );

    client.write_command(Command::Quit).await.unwrap();

    client.disconnect().await.unwrap();

    milter.shutdown().await.unwrap();
}

#[tokio::test]
async fn out_of_order_macros() {
    init_tracing_subscriber();

    let result = Arc::new(Mutex::new(HashMap::new()));

    let callbacks = {
        let result = result.clone();

        Callbacks::<()>::new()
            .on_connect(move |cx, _, _| {
                let result = result.clone();

                Box::pin(async move {
                    *result.lock().unwrap() = cx.macros.to_hash_map();

                    Status::Continue
                })
            })
    };

    let milter = Milter::spawn(LOCALHOST, callbacks, default_config())
        .await
        .unwrap();

    let mut client = Client::connect(milter.addr()).await.unwrap();

    // Register macros for the Connect stage. However, the OptNeg command clears
    // out the out-of-order macros and no macros are available for connect.

    client
        .write_command(Command::DefMacros(MacroPayload {
            stage: MacroStage::Connect,
            macros: vec![c_str!("{name}").into(), c_str!("value").into()],
        }))
        .await
        .unwrap();

    client
        .write_command(Command::OptNeg(OptNegPayload {
            version: PROTOCOL_VERSION,
            actions: Actions::all(),
            opts: ProtoOpts::all(),
        }))
        .await
        .unwrap();

    let reply = client.read_reply().await.unwrap();
    assert!(matches!(reply, Reply::OptNeg { .. }));

    client
        .write_command(Command::ConnInfo(ConnInfoPayload {
            hostname: c_str!("mail.example.com").into(),
            socket_info: SocketInfo::Inet((Ipv4Addr::LOCALHOST, 1234).into()),
        }))
        .await
        .unwrap();

    let reply = client.read_reply().await.unwrap();
    assert_eq!(reply, Reply::Continue);

    client.write_command(Command::Quit).await.unwrap();

    client.disconnect().await.unwrap();

    milter.shutdown().await.unwrap();

    let result = Arc::try_unwrap(result).unwrap().into_inner().unwrap();

    assert_eq!(result, HashMap::new());
}

#[tokio::test]
async fn requested_actions_not_available() {
    init_tracing_subscriber();

    let callbacks = Callbacks::<()>::new()
        .on_negotiate(|cx, _, _| {
            Box::pin(async {
                cx.requested_actions = Actions::ADD_RCPT | Actions::DELETE_RCPT;

                Status::Continue
            })
        });

    let milter = Milter::spawn(LOCALHOST, callbacks, default_config())
        .await
        .unwrap();

    let mut client = Client::connect(milter.addr()).await.unwrap();

    client
        .write_command(Command::OptNeg(OptNegPayload {
            version: PROTOCOL_VERSION,
            actions: Actions::ADD_HEADER | Actions::CHANGE_HEADER,
            opts: ProtoOpts::all(),
        }))
        .await
        .unwrap();

    // Option negotiation is unsuccessful, the connection is closed immediately.

    let e = client.read_reply().await.unwrap_err();
    assert_eq!(e.kind(), ErrorKind::UnexpectedEof);

    client.disconnect().await.unwrap();

    milter.shutdown().await.unwrap();
}
