use anyhow::Context;
use hickory_resolver::TokioAsyncResolver;
use lettre::message::header::ContentType;
use lettre::transport::smtp::client::TlsParameters;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use minijinja::{AutoEscape, Environment};
use serde_json::json;
use uuid::Uuid;

const FROM_ADDR: &str = "noreply@bythewood.me";
const HELO: &str = "bythewood.me";
const SMTP_TIMEOUT_SECS: u64 = 30;
const EMAIL_BASE_TEMPLATE: &str =
    include_str!("../templates/emails/property_email_base.html");

/// Property snapshot used to fill in the email metadata table. Computed at the
/// moment the alert fires so the values match what triggered it.
pub struct EmailContext {
    pub id: Uuid,
    pub name: String,
    pub url: String,
    pub current_status: i64,
    pub avg_response_time: i64,
}

struct Theme {
    email_title: &'static str,
    preheader: String,
    status_label: &'static str,
    event_title: &'static str,
    event_copy: &'static str,
    accent: &'static str,
    accent_bright: &'static str,
    accent_tint: &'static str,
    accent_border: &'static str,
}

fn theme_for(kind: &str, name: &str) -> Option<Theme> {
    match kind {
        "down" => Some(Theme {
            email_title: "Property down",
            preheader: format!("{name} is not responding"),
            status_label: "Down",
            event_title: "Your property is down.",
            event_copy: "We've observed two consecutive failed checks in a row. \
                         Monitoring will continue in the background and you'll get \
                         another note the moment it recovers.",
            accent: "#c47055",
            accent_bright: "#e38871",
            accent_tint: "#201712",
            accent_border: "#36231b",
        }),
        "recovery" => Some(Theme {
            email_title: "Property recovered",
            preheader: format!("{name} is back online"),
            status_label: "Recovered",
            event_title: "Your property is back online.",
            event_copy: "The latest check returned a healthy response. Everything \
                         looks normal again; we'll keep monitoring and alert you if \
                         anything changes.",
            accent: "#6b9e78",
            accent_bright: "#7db88c",
            accent_tint: "#191e17",
            accent_border: "#222d22",
        }),
        _ => None,
    }
}

pub(crate) fn render_preview_html(kind: &str, base_url: &str) -> anyhow::Result<String> {
    let theme = theme_for(kind, "example.com")
        .ok_or_else(|| anyhow::anyhow!("unknown kind: {kind} (use 'down' or 'recovery')"))?;
    let ctx = EmailContext {
        id: Uuid::nil(),
        name: "example.com".to_string(),
        url: "https://example.com".to_string(),
        current_status: if kind == "down" { 503 } else { 200 },
        avg_response_time: 184,
    };
    render_email_html(&theme, &ctx, base_url)
}

fn render_email_html(theme: &Theme, ctx: &EmailContext, base_url: &str) -> anyhow::Result<String> {
    let mut env = Environment::new();
    env.set_auto_escape_callback(|_| AutoEscape::Html);
    env.add_template("email_base.html", EMAIL_BASE_TEMPLATE)
        .context("compiling email template")?;
    let tmpl = env.get_template("email_base.html")?;
    let html = tmpl
        .render(minijinja::context! {
            email_title => theme.email_title,
            preheader => &theme.preheader,
            status_label => theme.status_label,
            event_title => theme.event_title,
            event_copy => theme.event_copy,
            accent => theme.accent,
            accent_bright => theme.accent_bright,
            accent_tint => theme.accent_tint,
            accent_border => theme.accent_border,
            BASE_URL => base_url.trim_end_matches('/'),
            property => minijinja::context! {
                id => ctx.id.to_string(),
                name => &ctx.name,
                url => &ctx.url,
                current_status => ctx.current_status,
                avg_response_time => ctx.avg_response_time,
            },
        })
        .context("rendering email template")?;
    Ok(html)
}

/// Fire a state-transition notification. `kind` is "down" or "recovery".
pub async fn fire(
    kind: &str,
    ctx: &EmailContext,
    base_url: &str,
    alert_email: Option<&str>,
    discord_webhook: Option<&str>,
) -> anyhow::Result<()> {
    let theme = theme_for(kind, &ctx.name).ok_or_else(|| anyhow::anyhow!("unknown alert kind: {kind}"))?;
    let subject = match kind {
        "down" => format!("Status: {} is down!", ctx.name),
        "recovery" => format!("Status: {} is back up!", ctx.name),
        _ => unreachable!(),
    };
    let html = render_email_html(&theme, ctx, base_url)?;

    let mut errors: Vec<anyhow::Error> = Vec::new();

    if let Some(to) = alert_email {
        if let Err(e) = send_email_via_mx(&subject, &html, to).await {
            errors.push(e.context("email"));
        }
    }
    if let Some(webhook) = discord_webhook {
        if let Err(e) = send_discord(kind, &ctx.url, webhook).await {
            errors.push(e.context("discord"));
        }
    }
    if !errors.is_empty() {
        let combined = errors.iter().map(|e| format!("{e:#}")).collect::<Vec<_>>().join("; ");
        anyhow::bail!("{combined}");
    }
    Ok(())
}

/// Direct-MX delivery: resolve the recipient domain's MX records, sort by
/// preference, try each with STARTTLS. No relay configured by default.
async fn send_email_via_mx(subject: &str, html: &str, to: &str) -> anyhow::Result<()> {
    let domain = to
        .rsplit_once('@')
        .map(|(_, d)| d.to_string())
        .ok_or_else(|| anyhow::anyhow!("invalid recipient: {to}"))?;

    let resolver = TokioAsyncResolver::tokio_from_system_conf()
        .context("creating dns resolver")?;
    let mx = resolver
        .mx_lookup(domain.as_str())
        .await
        .context("mx lookup")?;
    let mut records: Vec<_> = mx.iter().collect();
    records.sort_by_key(|r| r.preference());

    if records.is_empty() {
        anyhow::bail!("no MX records for {domain}");
    }

    let email = Message::builder()
        .from(FROM_ADDR.parse()?)
        .to(to.parse()?)
        .subject(subject)
        .header(ContentType::TEXT_HTML)
        .body(html.to_string())?;

    let mut last_err: Option<anyhow::Error> = None;
    for rec in records {
        let host = rec.exchange().to_utf8();
        let host = host.trim_end_matches('.').to_string();
        match try_one_mx(&host, &email).await {
            Ok(()) => return Ok(()),
            Err(e) => {
                tracing::warn!("MX {host} failed for {domain}: {e:#}");
                last_err = Some(e);
            }
        }
    }
    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all MX hosts failed for {domain}")))
}

async fn try_one_mx(host: &str, email: &Message) -> anyhow::Result<()> {
    let tls = TlsParameters::builder(host.to_string())
        // Receiving MTAs sometimes use self-signed/expired certs; opportunistic
        // STARTTLS with relaxed validation matches what the Python smtplib
        // version did. End-to-end privacy is the recipient's MTA's job.
        .dangerous_accept_invalid_certs(true)
        .dangerous_accept_invalid_hostnames(true)
        .build_rustls()?;

    let transport: AsyncSmtpTransport<Tokio1Executor> =
        AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host)
            .port(25)
            .hello_name(lettre::transport::smtp::extension::ClientId::Domain(HELO.into()))
            .timeout(Some(std::time::Duration::from_secs(SMTP_TIMEOUT_SECS)))
            .tls(lettre::transport::smtp::client::Tls::Opportunistic(tls))
            .build();

    transport.send(email.clone()).await.context("smtp send")?;
    Ok(())
}

async fn send_discord(kind: &str, url: &str, webhook: &str) -> anyhow::Result<()> {
    let (title, color, desc) = match kind {
        "down" => ("Status Alert", 16711680u32, format!("{url} is down!")),
        "recovery" => ("Status Recovery", 65280u32, format!("{url} is back up!")),
        _ => anyhow::bail!("unknown kind: {kind}"),
    };
    let payload = json!({
        "username": "Status",
        "embeds": [{
            "title": title,
            "description": desc,
            "color": color,
            "timestamp": chrono::Utc::now().to_rfc3339(),
        }],
    });
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(5))
        .build()?;
    let body = serde_json::to_string(&payload)?;
    let resp = client
        .post(webhook)
        .header("content-type", "application/json")
        .body(body)
        .send()
        .await?;
    if !resp.status().is_success() {
        anyhow::bail!("discord {} {}", resp.status(), resp.text().await.unwrap_or_default());
    }
    Ok(())
}
