// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.

//! Client for the external postnumber-resolver service.
//!
//! The resolver validates user-facing "postnumbers" (usernames) during
//! registration and returns whether a value is *allowed* or *reserved*.
//!
//! MAS usernames are restricted to `[a-z0-9]` which is the same alphabet
//! as the resolver.  The `normalize_for_resolver` helper lowercases the
//! input before sending it to the resolver.

use std::sync::LazyLock;

use mas_data_model::{PostnumberValidationConfig, PostnumberValidationFailureMode};
use mas_http::RequestBuilderExt as _;
use opentelemetry::{Key, KeyValue, metrics::Counter};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use url::Url;

use crate::METER;

// ---------------------------------------------------------------------------
// Metrics
// ---------------------------------------------------------------------------

static OUTCOME: Key = Key::from_static_str("postnumber.outcome");

static CHECK_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| {
    METER
        .u64_counter("mas.postnumber.check")
        .with_description("Number of postnumber resolver checks")
        .with_unit("{check}")
        .build()
});

// ---------------------------------------------------------------------------
// Wire types – mirror of the resolver's HTTP contract
// ---------------------------------------------------------------------------

/// Request payload sent to `POST /v1/check`.
#[derive(Debug, Serialize)]
struct CheckRequest<'a> {
    value: &'a str,
}

/// Status returned by the resolver on a successful check.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum CheckStatus {
    Allow,
    Reserved,
}

/// Full success response from `POST /v1/check`.
#[derive(Debug, Deserialize)]
pub struct CheckResponse {
    pub status: CheckStatus,
    #[allow(dead_code)]
    pub match_kind: serde_json::Value, // we don't need the enum on this side
    #[allow(dead_code)]
    pub matched_terms: Vec<String>,
    #[allow(dead_code)]
    pub matched_categories: Vec<String>,
    #[allow(dead_code)]
    pub pattern_family: Option<String>,
}

// ---------------------------------------------------------------------------
// Domain result
// ---------------------------------------------------------------------------

/// Maximum length the resolver accepts. Values longer than this after
/// stripping non-alphanumeric characters are not postnumbers — skip the
/// check and let MAS's own rules decide.
const MAX_RESOLVER_INPUT_LENGTH: usize = 20;

/// The outcome of a postnumber check that callers should act on.
#[derive(Debug)]
pub enum PostnumberOutcome {
    /// The postnumber is valid and available for any user.
    Allowed,
    /// The postnumber is valid but reserved by policy (admin-only).
    Reserved,
}

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

#[derive(Debug, Error)]
pub enum Error {
    /// HTTP-level failure (connection refused, timeout, TLS error, …).
    #[error("postnumber resolver request failed")]
    Request(#[from] reqwest::Error),

    /// The resolver returned an unexpected status code or unparseable body.
    #[error("postnumber resolver returned an unexpected response (status {status})")]
    UnexpectedResponse { status: reqwest::StatusCode },

    /// The resolver is temporarily unavailable (rate-limited, service down, …)
    /// and the configured failure-mode decides what happens.
    #[error("postnumber resolver is unavailable")]
    Unavailable,
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Check a postnumber against the resolver service.
///
/// Returns `Ok(None)` when postnumber validation is not configured (i.e. the
/// feature is disabled).
///
/// Returns `Ok(Some(outcome))` with the resolver's decision, or `Err` when the
/// resolver is unreachable and the failure mode is `Closed`.
#[tracing::instrument(
    name = "postnumber.check",
    skip_all,
    fields(postnumber.value = %value),
)]
pub async fn check(
    http_client: &reqwest::Client,
    config: Option<&PostnumberValidationConfig>,
    value: &str,
) -> Result<Option<PostnumberOutcome>, Error> {
    let Some(config) = config else {
        return Ok(None);
    };

    // Lowercase the input before sending to the resolver.  If the
    // result is empty or exceeds the resolver's max length, skip.
    let sanitized = normalize_for_resolver(value);
    if sanitized.is_empty() || sanitized.len() > MAX_RESOLVER_INPUT_LENGTH {
        tracing::debug!(
            original = %value,
            sanitized = %sanitized,
            "skipping postnumber check – value not in resolver's domain",
        );
        return Ok(None);
    }

    match do_check(http_client, &config.endpoint, config.timeout, &sanitized).await {
        Ok(outcome) => {
            let label = match &outcome {
                PostnumberOutcome::Allowed => "allowed",
                PostnumberOutcome::Reserved => "reserved",
            };
            CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), label)]);
            Ok(Some(outcome))
        }
        Err(Error::Unavailable) => {
            // Single retry with backoff for transient failures before
            // triggering the fail-open/fail-closed policy.
            tracing::info!("postnumber resolver unavailable – retrying once after 500ms");
            tokio::time::sleep(std::time::Duration::from_millis(500)).await;

            match do_check(http_client, &config.endpoint, config.timeout, &sanitized).await {
                Ok(outcome) => {
                    let label = match &outcome {
                        PostnumberOutcome::Allowed => "allowed",
                        PostnumberOutcome::Reserved => "reserved",
                    };
                    CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), label)]);
                    Ok(Some(outcome))
                }
                Err(Error::Unavailable) => match config.on_unavailable {
                    PostnumberValidationFailureMode::Open => {
                        tracing::warn!(
                            "postnumber resolver unavailable after retry – failing open"
                        );
                        CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "unavailable_open")]);
                        Ok(None)
                    }
                    PostnumberValidationFailureMode::Closed => {
                        tracing::warn!(
                            "postnumber resolver unavailable after retry – failing closed"
                        );
                        CHECK_COUNTER
                            .add(1, &[KeyValue::new(OUTCOME.clone(), "unavailable_closed")]);
                        Err(Error::Unavailable)
                    }
                },
                Err(e) => {
                    CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "error")]);
                    Err(e)
                }
            }
        }
        Err(e) => {
            CHECK_COUNTER.add(1, &[KeyValue::new(OUTCOME.clone(), "error")]);
            Err(e)
        }
    }
}

// ---------------------------------------------------------------------------
// Internal request logic
// ---------------------------------------------------------------------------

/// Lowercases the input for the resolver.  Since MAS now only allows
/// `[a-z0-9]` usernames, this is effectively just a `.to_ascii_lowercase()`
/// pass — no characters need to be stripped.
fn normalize_for_resolver(value: &str) -> String {
    value
        .chars()
        .map(|c| c.to_ascii_lowercase())
        .collect::<String>()
}

async fn do_check(
    http_client: &reqwest::Client,
    endpoint: &Url,
    timeout: std::time::Duration,
    value: &str,
) -> Result<PostnumberOutcome, Error> {
    // Ensure the endpoint has a trailing slash so that `Url::join`
    // appends "v1/check" as a sub-path rather than replacing the last
    // segment.  For example, without this normalization:
    //   "http://host:3001/api".join("v1/check") → "http://host:3001/v1/check"
    // With the trailing slash:
    //   "http://host:3001/api/".join("v1/check") → "http://host:3001/api/v1/check"
    let mut base = endpoint.clone();
    if !base.path().ends_with('/') {
        base.set_path(&format!("{}/", base.path()));
    }

    let url = base
        .join("v1/check")
        .expect("endpoint URL should be joinable with v1/check");

    let response = http_client
        .post(url)
        .timeout(timeout)
        .json(&CheckRequest { value })
        .send_traced()
        .await
        .map_err(|e| {
            if e.is_timeout() || e.is_connect() {
                tracing::warn!(error = &e as &dyn std::error::Error, "resolver unreachable");
                Error::Unavailable
            } else {
                Error::Request(e)
            }
        })?;

    let status = response.status();

    if status.is_success() {
        let body: CheckResponse = response
            .json()
            .await
            .map_err(|_| Error::UnexpectedResponse { status })?;

        match body.status {
            CheckStatus::Allow => Ok(PostnumberOutcome::Allowed),
            CheckStatus::Reserved => Ok(PostnumberOutcome::Reserved),
        }
    } else if status == reqwest::StatusCode::BAD_REQUEST
        || status == reqwest::StatusCode::UNPROCESSABLE_ENTITY
    {
        // The resolver rejected the format.  Since MAS is the authority on
        // username format (via OPA policy / `username_valid()`), we treat
        // this as "not a postnumber" and allow it through.
        tracing::debug!(
            %status,
            %value,
            "resolver returned client error – treating as allowed",
        );
        Ok(PostnumberOutcome::Allowed)
    } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS
        || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
        || status.is_server_error()
    {
        Err(Error::Unavailable)
    } else {
        Err(Error::UnexpectedResponse { status })
    }
}

#[cfg(test)]
mod tests {
    use wiremock::{
        Mock, MockServer, ResponseTemplate,
        matchers::{body_json, method, path},
    };

    use super::*;

    fn test_http_client() -> reqwest::Client {
        reqwest::Client::builder()
            .no_proxy()
            .build()
            .expect("failed to create test HTTP client")
    }

    fn test_config(server: &MockServer) -> PostnumberValidationConfig {
        PostnumberValidationConfig {
            endpoint: Url::parse(&format!("{}/", server.uri())).unwrap(),
            timeout: std::time::Duration::from_secs(5),
            on_unavailable: PostnumberValidationFailureMode::Closed,
        }
    }

    #[tokio::test]
    async fn test_allowed() {
        let server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(path("/v1/check"))
            .and(body_json(serde_json::json!({ "value": "alice" })))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "normalized": "alice",
                "status": "ALLOW",
                "match_kind": "allow",
                "matched_terms": [],
                "matched_categories": [],
                "pattern_family": null
            })))
            .expect(1)
            .mount(&server)
            .await;

        let client = test_http_client();
        let config = test_config(&server);

        let result = check(&client, Some(&config), "alice").await.unwrap();
        match result {
            Some(PostnumberOutcome::Allowed) => {}
            other => panic!("expected Allowed, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn test_reserved() {
        let server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(path("/v1/check"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "normalized": "admin",
                "status": "RESERVED",
                "match_kind": "exact",
                "matched_terms": ["admin"],
                "matched_categories": [],
                "pattern_family": null
            })))
            .mount(&server)
            .await;

        let client = test_http_client();
        let config = test_config(&server);

        let result = check(&client, Some(&config), "admin").await.unwrap();
        match result {
            Some(PostnumberOutcome::Reserved) => {}
            other => panic!("expected Reserved, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn test_resolver_400_treated_as_allowed() {
        // When the resolver rejects the format, MAS treats it as
        // "not a postnumber" and allows it — MAS's own rules handle format.
        let server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(path("/v1/check"))
            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                "error": "invalid_postnumber_format",
                "message": "postnumber must start with a letter"
            })))
            .mount(&server)
            .await;

        let client = test_http_client();
        let config = test_config(&server);

        let result = check(&client, Some(&config), "abc123").await.unwrap();
        match result {
            Some(PostnumberOutcome::Allowed) => { /* correct */ }
            other => panic!("expected Allowed, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn test_uppercase_lowered() {
        // normalize_for_resolver lowercases the input before sending.
        // Verify the resolver receives "alice", not "Alice".
        let server = MockServer::start().await;

        Mock::given(method("POST"))
            .and(path("/v1/check"))
            .and(body_json(serde_json::json!({ "value": "alice" })))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "normalized": "alice",
                "status": "ALLOW",
                "match_kind": "allow",
                "matched_terms": [],
                "matched_categories": [],
                "pattern_family": null
            })))
            .expect(1)
            .mount(&server)
            .await;

        let client = test_http_client();
        let config = test_config(&server);

        // "Alice" → "alice" sent to resolver
        let result = check(&client, Some(&config), "Alice").await.unwrap();
        match result {
            Some(PostnumberOutcome::Allowed) => {}
            other => panic!("expected Allowed, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn test_long_username_skipped() {
        // Usernames > 20 alphanumeric chars are outside the resolver's
        // domain and should be skipped entirely (returns None).
        let server = MockServer::start().await;
        // Don't mount any mock — if the resolver is called, the test fails.

        let client = test_http_client();
        let config = test_config(&server);

        let long_name = "a".repeat(21);
        let result = check(&client, Some(&config), &long_name).await.unwrap();
        assert!(result.is_none(), "expected None for long username");
    }

    #[tokio::test]
    async fn test_unavailable_closed() {
        // No server running → connection refused
        let config = PostnumberValidationConfig {
            endpoint: Url::parse("http://127.0.0.1:1/").unwrap(),
            timeout: std::time::Duration::from_millis(200),
            on_unavailable: PostnumberValidationFailureMode::Closed,
        };

        let client = test_http_client();
        let result = check(&client, Some(&config), "test").await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_unavailable_open() {
        let config = PostnumberValidationConfig {
            endpoint: Url::parse("http://127.0.0.1:1/").unwrap(),
            timeout: std::time::Duration::from_millis(200),
            on_unavailable: PostnumberValidationFailureMode::Open,
        };

        let client = test_http_client();
        let result = check(&client, Some(&config), "test").await.unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_no_config() {
        let client = test_http_client();
        let result = check(&client, None, "anything").await.unwrap();
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_rate_limited() {
        let server = MockServer::start().await;

        // With retry, the resolver will be called twice before giving up.
        Mock::given(method("POST"))
            .and(path("/v1/check"))
            .respond_with(ResponseTemplate::new(429).set_body_json(serde_json::json!({
                "error": "rate_limited",
                "message": "Too many requests"
            })))
            .expect(2)
            .mount(&server)
            .await;

        let client = test_http_client();
        let config = test_config(&server);

        let result = check(&client, Some(&config), "test").await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_retry_succeeds_on_second_attempt() {
        // First call returns 503, second call succeeds.
        let server = MockServer::start().await;

        // Mount a 503 response that only fires once…
        Mock::given(method("POST"))
            .and(path("/v1/check"))
            .respond_with(ResponseTemplate::new(503))
            .up_to_n_times(1)
            .mount(&server)
            .await;

        // …then an ALLOW response for subsequent calls.
        Mock::given(method("POST"))
            .and(path("/v1/check"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "normalized": "bob",
                "status": "ALLOW",
                "match_kind": "allow",
                "matched_terms": [],
                "matched_categories": [],
                "pattern_family": null
            })))
            .mount(&server)
            .await;

        let client = test_http_client();
        let config = test_config(&server);

        let result = check(&client, Some(&config), "bob").await.unwrap();
        match result {
            Some(PostnumberOutcome::Allowed) => {}
            other => panic!("expected Allowed after retry, got {other:?}"),
        }
    }

    #[test]
    fn test_endpoint_url_normalization() {
        // Verify that the URL join works correctly with and without trailing slash.
        let with_slash = Url::parse("http://localhost:3001/").unwrap();
        let without_slash = Url::parse("http://localhost:3001").unwrap();
        let with_path = Url::parse("http://localhost:3001/api").unwrap();
        let with_path_slash = Url::parse("http://localhost:3001/api/").unwrap();

        // Helper that mirrors the normalization in do_check
        let normalize_and_join = |endpoint: &Url| -> String {
            let mut base = endpoint.clone();
            if !base.path().ends_with('/') {
                base.set_path(&format!("{}/", base.path()));
            }
            base.join("v1/check").unwrap().to_string()
        };

        assert_eq!(
            normalize_and_join(&with_slash),
            "http://localhost:3001/v1/check"
        );
        assert_eq!(
            normalize_and_join(&without_slash),
            "http://localhost:3001/v1/check"
        );
        assert_eq!(
            normalize_and_join(&with_path),
            "http://localhost:3001/api/v1/check"
        );
        assert_eq!(
            normalize_and_join(&with_path_slash),
            "http://localhost:3001/api/v1/check"
        );
    }
}
