/*
 * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
 *
 * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
 */

use std::future::Future;

use common::{auth::AccessToken, Server};
use directory::{
    backend::internal::{manage::ManageDirectory, PrincipalField},
    Permission, Type,
};
use hyper::Method;
use mail_auth::report::{
    tlsrpt::{FailureDetails, Policy, TlsReport},
    Feedback,
};
use serde_json::json;
use smtp::reporting::analysis::IncomingReport;
use store::{
    write::{key::DeserializeBigEndian, BatchBuilder, Bincode, ReportClass, ValueClass},
    Deserialize, IterateParams, ValueKey, U64_LEN,
};
use trc::AddContext;
use utils::url_params::UrlParams;

use crate::api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse};

use super::decode_path_element;

enum ReportType {
    Dmarc,
    Tls,
    Arf,
}

pub trait ManageReports: Sync + Send {
    fn handle_manage_reports(
        &self,
        req: &HttpRequest,
        path: Vec<&str>,
        access_token: &AccessToken,
    ) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
}

impl ManageReports for Server {
    async fn handle_manage_reports(
        &self,
        req: &HttpRequest,
        path: Vec<&str>,
        access_token: &AccessToken,
    ) -> trc::Result<HttpResponse> {
        // SPDX-SnippetBegin
        // SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
        // SPDX-License-Identifier: LicenseRef-SEL

        // Limit to tenant domains
        let mut tenant_domains: Option<Vec<String>> = None;
        #[cfg(feature = "enterprise")]
        if self.core.is_enterprise_edition() {
            if let Some(tenant) = access_token.tenant {
                tenant_domains = self
                    .core
                    .storage
                    .data
                    .list_principals(
                        None,
                        tenant.id.into(),
                        &[Type::Domain],
                        &[PrincipalField::Name],
                        0,
                        0,
                    )
                    .await
                    .map(|principals| {
                        principals
                            .items
                            .into_iter()
                            .filter_map(|mut p| p.take_str(PrincipalField::Name))
                            .collect::<Vec<_>>()
                    })
                    .caused_by(trc::location!())?
                    .into();
            }
        }

        // SPDX-SnippetEnd

        match (
            path.get(1).copied().unwrap_or_default(),
            path.get(2).copied().map(decode_path_element),
            req.method(),
        ) {
            (class @ ("dmarc" | "tls" | "arf"), None, &Method::GET) => {
                // Validate the access token
                access_token.assert_has_permission(Permission::IncomingReportList)?;

                let params = UrlParams::new(req.uri().query());
                let filter = params.get("text");
                let page: usize = params.parse::<usize>("page").unwrap_or_default();
                let limit: usize = params.parse::<usize>("limit").unwrap_or_default();

                let range_start = params.parse::<u64>("range-start").unwrap_or_default();
                let range_end = params.parse::<u64>("range-end").unwrap_or(u64::MAX);
                let max_total = params.parse::<usize>("max-total").unwrap_or_default();

                let (from_key, to_key, typ) = match class {
                    "dmarc" => (
                        ValueKey::from(ValueClass::Report(ReportClass::Dmarc {
                            id: range_start,
                            expires: 0,
                        })),
                        ValueKey::from(ValueClass::Report(ReportClass::Dmarc {
                            id: range_end,
                            expires: u64::MAX,
                        })),
                        ReportType::Dmarc,
                    ),
                    "tls" => (
                        ValueKey::from(ValueClass::Report(ReportClass::Tls {
                            id: range_start,
                            expires: 0,
                        })),
                        ValueKey::from(ValueClass::Report(ReportClass::Tls {
                            id: range_end,
                            expires: u64::MAX,
                        })),
                        ReportType::Tls,
                    ),
                    "arf" => (
                        ValueKey::from(ValueClass::Report(ReportClass::Arf {
                            id: range_start,
                            expires: 0,
                        })),
                        ValueKey::from(ValueClass::Report(ReportClass::Arf {
                            id: range_end,
                            expires: u64::MAX,
                        })),
                        ReportType::Arf,
                    ),
                    _ => unreachable!(),
                };

                let mut results = Vec::new();
                let mut offset = page.saturating_sub(1) * limit;
                let mut total = 0;
                let mut last_id = 0;
                let has_filters = filter.is_some() || tenant_domains.is_some();
                self.core
                    .storage
                    .data
                    .iterate(
                        IterateParams::new(from_key, to_key)
                            .set_values(has_filters)
                            .descending(),
                        |key, value| {
                            // Skip chunked records
                            let id = key.deserialize_be_u64(U64_LEN + 1)?;
                            if id == last_id {
                                return Ok(true);
                            }
                            last_id = id;

                            // TODO: Support filtering chunked records (over 10MB) on FDB
                            let matches = if has_filters {
                                match typ {
                                    ReportType::Dmarc => {
                                        let report = Bincode::<
                                            IncomingReport<mail_auth::report::Report>,
                                        >::deserialize(
                                            value
                                        )
                                        .caused_by(trc::location!())?
                                        .inner;

                                        filter.map_or(true, |f| report.contains(f))
                                            && tenant_domains
                                                .as_ref()
                                                .map_or(true, |domains| report.has_domain(domains))
                                    }
                                    ReportType::Tls => {
                                        let report =
                                            Bincode::<IncomingReport<TlsReport>>::deserialize(
                                                value,
                                            )
                                            .caused_by(trc::location!())?
                                            .inner;

                                        filter.map_or(true, |f| report.contains(f))
                                            && tenant_domains
                                                .as_ref()
                                                .map_or(true, |domains| report.has_domain(domains))
                                    }
                                    ReportType::Arf => {
                                        let report =
                                            Bincode::<IncomingReport<Feedback>>::deserialize(value)
                                                .caused_by(trc::location!())?
                                                .inner;

                                        filter.map_or(true, |f| report.contains(f))
                                            && tenant_domains
                                                .as_ref()
                                                .map_or(true, |domains| report.has_domain(domains))
                                    }
                                }
                            } else {
                                true
                            };

                            if matches {
                                if offset == 0 {
                                    if limit == 0 || results.len() < limit {
                                        results.push(format!(
                                            "{}_{}",
                                            id,
                                            key.deserialize_be_u64(1)?
                                        ));
                                    }
                                } else {
                                    offset -= 1;
                                }

                                total += 1;
                            }

                            Ok(max_total == 0 || total < max_total)
                        },
                    )
                    .await
                    .caused_by(trc::location!())?;

                Ok(JsonResponse::new(json!({
                        "data": {
                            "items": results,
                            "total": total,
                        },
                }))
                .into_http_response())
            }
            (class @ ("dmarc" | "tls" | "arf"), Some(report_id), &Method::GET) => {
                // Validate the access token
                access_token.assert_has_permission(Permission::IncomingReportGet)?;

                if let Some(report_id) = parse_incoming_report_id(class, report_id.as_ref()) {
                    match &report_id {
                        ReportClass::Tls { .. } => match self
                            .core
                            .storage
                            .data
                            .get_value::<Bincode<IncomingReport<TlsReport>>>(ValueKey::from(
                                ValueClass::Report(report_id),
                            ))
                            .await?
                        {
                            Some(report)
                                if tenant_domains
                                    .as_ref()
                                    .map_or(true, |domains| report.inner.has_domain(domains)) =>
                            {
                                Ok(JsonResponse::new(json!({
                                        "data": report.inner,
                                }))
                                .into_http_response())
                            }
                            _ => Err(trc::ResourceEvent::NotFound.into_err()),
                        },
                        ReportClass::Dmarc { .. } => match self
                            .core
                            .storage
                            .data
                            .get_value::<Bincode<IncomingReport<mail_auth::report::Report>>>(
                                ValueKey::from(ValueClass::Report(report_id)),
                            )
                            .await?
                        {
                            Some(report)
                                if tenant_domains
                                    .as_ref()
                                    .map_or(true, |domains| report.inner.has_domain(domains)) =>
                            {
                                Ok(JsonResponse::new(json!({
                                        "data": report.inner,
                                }))
                                .into_http_response())
                            }
                            _ => Err(trc::ResourceEvent::NotFound.into_err()),
                        },
                        ReportClass::Arf { .. } => match self
                            .core
                            .storage
                            .data
                            .get_value::<Bincode<IncomingReport<Feedback>>>(ValueKey::from(
                                ValueClass::Report(report_id),
                            ))
                            .await?
                        {
                            Some(report)
                                if tenant_domains
                                    .as_ref()
                                    .map_or(true, |domains| report.inner.has_domain(domains)) =>
                            {
                                Ok(JsonResponse::new(json!({
                                        "data": report.inner,
                                }))
                                .into_http_response())
                            }
                            _ => Err(trc::ResourceEvent::NotFound.into_err()),
                        },
                    }
                } else {
                    Err(trc::ResourceEvent::NotFound.into_err())
                }
            }
            (class @ ("dmarc" | "tls" | "arf"), Some(report_id), &Method::DELETE) => {
                // Validate the access token
                access_token.assert_has_permission(Permission::IncomingReportDelete)?;

                if let Some(report_id) = parse_incoming_report_id(class, report_id.as_ref()) {
                    if let Some(domains) = &tenant_domains {
                        let is_tenant_report = match &report_id {
                            ReportClass::Tls { .. } => self
                                .core
                                .storage
                                .data
                                .get_value::<Bincode<IncomingReport<TlsReport>>>(ValueKey::from(
                                    ValueClass::Report(report_id.clone()),
                                ))
                                .await?
                                .map_or(true, |report| report.inner.has_domain(domains)),
                            ReportClass::Dmarc { .. } => self
                                .core
                                .storage
                                .data
                                .get_value::<Bincode<IncomingReport<mail_auth::report::Report>>>(
                                    ValueKey::from(ValueClass::Report(report_id.clone())),
                                )
                                .await?
                                .map_or(true, |report| report.inner.has_domain(domains)),

                            ReportClass::Arf { .. } => self
                                .core
                                .storage
                                .data
                                .get_value::<Bincode<IncomingReport<Feedback>>>(ValueKey::from(
                                    ValueClass::Report(report_id.clone()),
                                ))
                                .await?
                                .map_or(true, |report| report.inner.has_domain(domains)),
                        };

                        if !is_tenant_report {
                            return Err(trc::ResourceEvent::NotFound.into_err());
                        }
                    }

                    let mut batch = BatchBuilder::new();
                    batch.clear(ValueClass::Report(report_id));
                    self.core.storage.data.write(batch.build()).await?;

                    Ok(JsonResponse::new(json!({
                            "data": true,
                    }))
                    .into_http_response())
                } else {
                    Err(trc::ResourceEvent::NotFound.into_err())
                }
            }
            _ => Err(trc::ResourceEvent::NotFound.into_err()),
        }
    }
}

fn parse_incoming_report_id(class: &str, id: &str) -> Option<ReportClass> {
    let mut parts = id.split('_');
    let id = parts.next()?.parse().ok()?;
    let expires = parts.next()?.parse().ok()?;
    match class {
        "dmarc" => Some(ReportClass::Dmarc { id, expires }),
        "tls" => Some(ReportClass::Tls { id, expires }),
        "arf" => Some(ReportClass::Arf { id, expires }),
        _ => None,
    }
}

impl From<&str> for ReportType {
    fn from(s: &str) -> Self {
        match s {
            "dmarc" => Self::Dmarc,
            "tls" => Self::Tls,
            "arf" => Self::Arf,
            _ => unreachable!(),
        }
    }
}

trait Contains {
    fn contains(&self, text: &str) -> bool;
}

impl Contains for mail_auth::report::Report {
    fn contains(&self, text: &str) -> bool {
        self.domain().contains(text)
            || self.org_name().to_lowercase().contains(text)
            || self.report_id().contains(text)
            || self
                .extra_contact_info()
                .map_or(false, |c| c.to_lowercase().contains(text))
            || self.records().iter().any(|record| record.contains(text))
    }
}

impl Contains for mail_auth::report::Record {
    fn contains(&self, filter: &str) -> bool {
        self.envelope_from().contains(filter)
            || self.header_from().contains(filter)
            || self.envelope_to().map_or(false, |to| to.contains(filter))
            || self.dkim_auth_result().iter().any(|dkim| {
                dkim.domain().contains(filter)
                    || dkim.selector().contains(filter)
                    || dkim
                        .human_result()
                        .as_ref()
                        .map_or(false, |r| r.contains(filter))
            })
            || self.spf_auth_result().iter().any(|spf| {
                spf.domain().contains(filter)
                    || spf.human_result().map_or(false, |r| r.contains(filter))
            })
            || self
                .source_ip()
                .map_or(false, |ip| ip.to_string().contains(filter))
    }
}

impl Contains for TlsReport {
    fn contains(&self, text: &str) -> bool {
        self.organization_name
            .as_ref()
            .map_or(false, |o| o.to_lowercase().contains(text))
            || self
                .contact_info
                .as_ref()
                .map_or(false, |c| c.to_lowercase().contains(text))
            || self.report_id.contains(text)
            || self.policies.iter().any(|p| p.contains(text))
    }
}

impl Contains for Policy {
    fn contains(&self, filter: &str) -> bool {
        self.policy.policy_domain.contains(filter)
            || self
                .policy
                .policy_string
                .iter()
                .any(|s| s.to_lowercase().contains(filter))
            || self
                .policy
                .mx_host
                .iter()
                .any(|s| s.to_lowercase().contains(filter))
            || self.failure_details.iter().any(|f| f.contains(filter))
    }
}

impl Contains for FailureDetails {
    fn contains(&self, filter: &str) -> bool {
        self.sending_mta_ip
            .map_or(false, |s| s.to_string().contains(filter))
            || self
                .receiving_ip
                .map_or(false, |s| s.to_string().contains(filter))
            || self
                .receiving_mx_hostname
                .as_ref()
                .map_or(false, |s| s.contains(filter))
            || self
                .receiving_mx_helo
                .as_ref()
                .map_or(false, |s| s.contains(filter))
            || self
                .additional_information
                .as_ref()
                .map_or(false, |s| s.contains(filter))
            || self
                .failure_reason_code
                .as_ref()
                .map_or(false, |s| s.contains(filter))
    }
}

impl Contains for Feedback<'_> {
    fn contains(&self, text: &str) -> bool {
        // Check if any of the string fields contain the filter
        self.authentication_results()
            .iter()
            .any(|s| s.contains(text))
            || self
                .original_envelope_id()
                .map_or(false, |s| s.contains(text))
            || self
                .original_mail_from()
                .map_or(false, |s| s.contains(text))
            || self.original_rcpt_to().map_or(false, |s| s.contains(text))
            || self.reported_domain().iter().any(|s| s.contains(text))
            || self.reported_uri().iter().any(|s| s.contains(text))
            || self.reporting_mta().map_or(false, |s| s.contains(text))
            || self.user_agent().map_or(false, |s| s.contains(text))
            || self.dkim_adsp_dns().map_or(false, |s| s.contains(text))
            || self
                .dkim_canonicalized_body()
                .map_or(false, |s| s.contains(text))
            || self
                .dkim_canonicalized_header()
                .map_or(false, |s| s.contains(text))
            || self.dkim_domain().map_or(false, |s| s.contains(text))
            || self.dkim_identity().map_or(false, |s| s.contains(text))
            || self.dkim_selector().map_or(false, |s| s.contains(text))
            || self.dkim_selector_dns().map_or(false, |s| s.contains(text))
            || self.spf_dns().map_or(false, |s| s.contains(text))
            || self.message().map_or(false, |s| s.contains(text))
            || self.headers().map_or(false, |s| s.contains(text))
    }
}

impl<T: Contains> Contains for IncomingReport<T> {
    fn contains(&self, text: &str) -> bool {
        self.from.to_lowercase().contains(text)
            || self.to.iter().any(|to| to.to_lowercase().contains(text))
            || self.subject.to_lowercase().contains(text)
            || self.report.contains(text)
    }
}
