//! Webhook notification via HTTP POST.
//!
//! Sends hook events to external services (Slack, Discord, etc.)

use serde::Serialize;
use tracing::{debug, error};

use crate::config::WebhookConfig;
use crate::error::NotificationError;
use crate::hooks::HookEvent;

/// Target service type detected from webhook URL
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebhookTarget {
    /// Slack (Block Kit format)
    Slack,
    /// Discord (Embeds format)
    Discord,
    /// Generic (minimal JSON)
    Generic,
}

impl WebhookTarget {
    /// Detect target from URL pattern
    #[must_use]
    pub fn detect(url: &str) -> Self {
        if url.contains("hooks.slack.com") {
            Self::Slack
        } else if url.contains("discord.com/api/webhooks")
            || url.contains("discordapp.com/api/webhooks")
        {
            Self::Discord
        } else {
            Self::Generic
        }
    }
}

/// Format timestamp as ISO8601 string
fn format_timestamp_iso8601(timestamp: std::time::SystemTime) -> String {
    use chrono::{DateTime, SecondsFormat, Utc};
    DateTime::<Utc>::from(timestamp).to_rfc3339_opts(SecondsFormat::Secs, true)
}

// ─────────────────────────────────────────────────────────────
// Slack Payload (Block Kit)
// ─────────────────────────────────────────────────────────────

/// Slack Block Kit payload
#[derive(Debug, Serialize)]
pub struct SlackPayload {
    text: String,
    blocks: Vec<SlackBlock>,
}

#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum SlackBlock {
    Section { text: SlackText },
    Context { elements: Vec<SlackText> },
}

#[derive(Debug, Serialize)]
struct SlackText {
    #[serde(rename = "type")]
    text_type: String,
    text: String,
}

impl SlackPayload {
    /// Build Slack payload from hook event
    #[must_use]
    pub fn from_event(event: &HookEvent, session_name: &str) -> Self {
        let message = event
            .message()
            .unwrap_or_else(|| format!("{:?} event occurred", event.event_type));
        let timestamp = format_timestamp_iso8601(event.timestamp);

        Self {
            text: format!("[tazuna] {session_name}: {message}"),
            blocks: vec![
                SlackBlock::Section {
                    text: SlackText {
                        text_type: "mrkdwn".to_string(),
                        text: format!("*{message}*"),
                    },
                },
                SlackBlock::Context {
                    elements: vec![SlackText {
                        text_type: "mrkdwn".to_string(),
                        text: format!(":bookmark: {session_name} | :clock1: {timestamp}"),
                    }],
                },
            ],
        }
    }
}

// ─────────────────────────────────────────────────────────────
// Discord Payload (Embeds)
// ─────────────────────────────────────────────────────────────

/// Discord Embeds payload
#[derive(Debug, Serialize)]
pub struct DiscordPayload {
    embeds: Vec<DiscordEmbed>,
}

#[derive(Debug, Serialize)]
struct DiscordEmbed {
    title: String,
    description: String,
    color: u32,
    fields: Vec<DiscordField>,
    timestamp: String,
    footer: DiscordFooter,
}

#[derive(Debug, Serialize)]
struct DiscordField {
    name: String,
    value: String,
    inline: bool,
}

#[derive(Debug, Serialize)]
struct DiscordFooter {
    text: String,
}

impl DiscordPayload {
    /// Build Discord payload from hook event
    #[must_use]
    pub fn from_event(event: &HookEvent, session_name: &str) -> Self {
        let message = event
            .message()
            .unwrap_or_else(|| format!("{:?} event occurred", event.event_type));
        let timestamp = format_timestamp_iso8601(event.timestamp);

        Self {
            embeds: vec![DiscordEmbed {
                title: "tazuna".to_string(),
                description: message,
                color: 16_750_848, // Orange
                fields: vec![DiscordField {
                    name: "Session".to_string(),
                    value: session_name.to_string(),
                    inline: true,
                }],
                timestamp,
                footer: DiscordFooter {
                    text: "tazuna".to_string(),
                },
            }],
        }
    }
}

// ─────────────────────────────────────────────────────────────
// Generic Payload (backward compatible)
// ─────────────────────────────────────────────────────────────

/// Generic minimal payload
#[derive(Debug, Serialize)]
pub struct GenericPayload {
    text: String,
    session: String,
    timestamp: String,
}

impl GenericPayload {
    /// Build generic payload from hook event
    #[must_use]
    pub fn from_event(event: &HookEvent, session_name: &str) -> Self {
        let message = event
            .message()
            .unwrap_or_else(|| format!("{:?} event occurred", event.event_type));
        let timestamp = format_timestamp_iso8601(event.timestamp);

        Self {
            text: format!("[tazuna] {session_name}: {message}"),
            session: session_name.to_string(),
            timestamp,
        }
    }
}

/// Webhook notification sender
pub struct WebhookNotifier {
    client: reqwest::Client,
    url: String,
    target: WebhookTarget,
}

impl WebhookNotifier {
    /// Create new webhook notifier
    #[must_use]
    pub fn new(config: &WebhookConfig) -> Self {
        Self {
            client: reqwest::Client::new(),
            target: WebhookTarget::detect(&config.url),
            url: config.url.clone(),
        }
    }

    /// Create with custom client (for testing)
    #[cfg(test)]
    #[must_use]
    pub(crate) fn with_client(client: reqwest::Client, url: String) -> Self {
        let target = WebhookTarget::detect(&url);
        Self {
            client,
            url,
            target,
        }
    }

    /// Send webhook notification
    pub async fn send(
        &self,
        event: &HookEvent,
        session_name: &str,
    ) -> Result<(), NotificationError> {
        debug!(
            "Sending webhook to {} (target: {:?})",
            self.url, self.target
        );

        let response = match self.target {
            WebhookTarget::Slack => {
                let payload = SlackPayload::from_event(event, session_name);
                self.client.post(&self.url).json(&payload).send().await
            }
            WebhookTarget::Discord => {
                let payload = DiscordPayload::from_event(event, session_name);
                self.client.post(&self.url).json(&payload).send().await
            }
            WebhookTarget::Generic => {
                let payload = GenericPayload::from_event(event, session_name);
                self.client.post(&self.url).json(&payload).send().await
            }
        }
        .map_err(NotificationError::WebhookFailed)?;

        if !response.status().is_success() {
            error!("Webhook failed with status: {}", response.status());
        }

        Ok(())
    }

    /// Get the configured URL
    #[cfg(test)]
    #[must_use]
    pub(crate) fn url(&self) -> &str {
        &self.url
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::session::SessionId;
    use rstest::rstest;
    use uuid::Uuid;

    fn test_session_id() -> SessionId {
        SessionId::from(Uuid::new_v4())
    }

    fn make_event(hook_name: &str, msg_or_tool: &str) -> HookEvent {
        let payload = if hook_name == "Notification" {
            serde_json::json!({"hook_event_name": hook_name, "message": msg_or_tool})
        } else {
            serde_json::json!({"hook_event_name": hook_name, "tool_name": msg_or_tool})
        };
        HookEvent::from_payload(test_session_id(), payload).expect("create event")
    }

    #[test]
    fn url_accessor() {
        let config = WebhookConfig {
            enabled: true,
            url: "https://example.com".to_string(),
        };
        assert_eq!(WebhookNotifier::new(&config).url(), "https://example.com");
    }

    #[test]
    fn with_client_constructor() {
        let notifier =
            WebhookNotifier::with_client(reqwest::Client::new(), "http://test".to_string());
        assert_eq!(notifier.url(), "http://test");
    }

    #[rstest]
    #[case(200)]
    #[case(500)]
    #[tokio::test]
    async fn send_returns_ok(#[case] status: u16) {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let mock_server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/webhook"))
            .respond_with(ResponseTemplate::new(status))
            .mount(&mock_server)
            .await;

        let notifier = WebhookNotifier::with_client(
            reqwest::Client::new(),
            format!("{}/webhook", mock_server.uri()),
        );
        assert!(
            notifier
                .send(&make_event("Notification", "Test"), "s")
                .await
                .is_ok()
        );
    }

    // WebhookTarget detection tests
    #[rstest]
    #[case("https://hooks.slack.com/services/T00/B00/xxx", WebhookTarget::Slack)]
    #[case("https://hooks.slack.com/workflows/T00/A00/xxx", WebhookTarget::Slack)]
    #[case("https://discord.com/api/webhooks/123/abc", WebhookTarget::Discord)]
    #[case("https://discordapp.com/api/webhooks/123/abc", WebhookTarget::Discord)]
    #[case("https://example.com/webhook", WebhookTarget::Generic)]
    #[case("https://my-server.com/slack-webhook", WebhookTarget::Generic)]
    fn detect_webhook_target(#[case] url: &str, #[case] expected: WebhookTarget) {
        assert_eq!(WebhookTarget::detect(url), expected);
    }

    // SlackPayload structure tests
    #[test]
    fn slack_payload_has_required_structure() {
        let event = make_event("Notification", "Permission needed");
        let payload = SlackPayload::from_event(&event, "test-session");
        let json = serde_json::to_value(&payload).expect("serialize");

        // Must have text field (required by Slack)
        assert!(json.get("text").is_some());
        assert!(json["text"].as_str().unwrap().contains("test-session"));

        // Must have blocks array
        assert!(json.get("blocks").is_some());
        assert!(json["blocks"].is_array());

        // First block: section with message
        let blocks = json["blocks"].as_array().unwrap();
        assert!(blocks.len() >= 2);
        assert_eq!(blocks[0]["type"], "section");
        assert!(
            blocks[0]["text"]["text"]
                .as_str()
                .unwrap()
                .contains("Permission needed")
        );

        // Second block: context with session/timestamp
        assert_eq!(blocks[1]["type"], "context");
    }

    // DiscordPayload structure tests
    #[test]
    fn discord_payload_has_required_structure() {
        let event = make_event("Notification", "Action required");
        let payload = DiscordPayload::from_event(&event, "my-session");
        let json = serde_json::to_value(&payload).expect("serialize");

        // Must have embeds array
        assert!(json.get("embeds").is_some());
        let embeds = json["embeds"].as_array().unwrap();
        assert_eq!(embeds.len(), 1);

        let embed = &embeds[0];
        // Title
        assert_eq!(embed["title"], "tazuna");
        // Description contains message
        assert!(
            embed["description"]
                .as_str()
                .unwrap()
                .contains("Action required")
        );
        // Color (orange)
        assert_eq!(embed["color"], 16_750_848);
        // Fields with session
        assert!(embed["fields"].is_array());
        // Timestamp in ISO8601
        assert!(embed.get("timestamp").is_some());
        // Footer
        assert_eq!(embed["footer"]["text"], "tazuna");
    }

    // GenericPayload structure tests (backward compat)
    #[test]
    fn generic_payload_backward_compat() {
        let event = make_event("Notification", "Test message");
        let payload = GenericPayload::from_event(&event, "sess-1");
        let json = serde_json::to_value(&payload).expect("serialize");

        // text field with format: [tazuna] session: message
        assert!(
            json["text"]
                .as_str()
                .unwrap()
                .starts_with("[tazuna] sess-1:")
        );
        assert!(json["text"].as_str().unwrap().contains("Test message"));

        // session field
        assert_eq!(json["session"], "sess-1");

        // timestamp field
        assert!(json.get("timestamp").is_some());
    }

    // Wiremock integration tests per target
    #[tokio::test]
    async fn send_slack_payload_has_blocks() {
        use wiremock::matchers::{body_partial_json, method};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let mock_server = MockServer::start().await;

        // Validate request body contains "blocks"
        Mock::given(method("POST"))
            .and(body_partial_json(serde_json::json!({
                "blocks": [{"type": "section"}]
            })))
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&mock_server)
            .await;

        // URL with "hooks.slack.com" triggers Slack payload
        let notifier = WebhookNotifier::with_client(
            reqwest::Client::new(),
            format!("{}?host=hooks.slack.com", mock_server.uri()),
        );

        notifier
            .send(&make_event("Notification", "Test"), "test-session")
            .await
            .expect("send ok");
    }

    #[tokio::test]
    async fn send_discord_payload_has_embeds() {
        use wiremock::matchers::{body_partial_json, method};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let mock_server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(body_partial_json(serde_json::json!({
                "embeds": [{"title": "tazuna"}]
            })))
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&mock_server)
            .await;

        // URL with "discord.com/api/webhooks" triggers Discord payload
        let notifier = WebhookNotifier::with_client(
            reqwest::Client::new(),
            format!("{}?host=discord.com/api/webhooks", mock_server.uri()),
        );

        notifier
            .send(&make_event("Notification", "Test"), "test-session")
            .await
            .expect("send ok");
    }

    #[tokio::test]
    async fn send_generic_payload_minimal() {
        use wiremock::matchers::{body_partial_json, method};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let mock_server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(body_partial_json(serde_json::json!({
                "session": "test-session"
            })))
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&mock_server)
            .await;

        // Generic URL (no slack/discord pattern)
        let notifier = WebhookNotifier::with_client(reqwest::Client::new(), mock_server.uri());

        notifier
            .send(&make_event("Notification", "Test"), "test-session")
            .await
            .expect("send ok");
    }
}
