//! URL parsing and RFC 3986 percent-encoding (no external `url` crate).

use std::fmt::Write as _;

use exn::OptionExt as _;

// ────────────────────────────────────────────────────────────────────────────
// URL parsing helpers (sans dépendance url/http)
// ────────────────────────────────────────────────────────────────────────────

/// Components of a parsed URL needed for `SigV4` canonicalisation.
#[derive(Debug)]
pub struct ParsedUrl {
    /// Authority component (`host[:port]`). The port is preserved because
    /// `SigV4` requires it in the `Host` header when non-default.
    pub host: String,
    /// Canonical URI: path with each segment percent-encoded per RFC 3986.
    /// For non-S3 services the caller must apply a second encoding pass.
    pub path: String,
    /// Canonical query string: sorted, percent-encoded `key=value` pairs
    /// joined by `&`. Empty when the URL has no query component.
    pub canonical_query: String,
}

/// `https://` scheme prefix; the standard scheme for S3 requests.
///
/// Listed first in [`parse_url`] so the common secure case is matched before
/// the plain-HTTP fallback.
const SCHEME_HTTPS: &str = "https://";

/// `http://` scheme prefix, accepted for local or non-TLS-capable endpoints.
///
/// Supported as a fallback; `SigV4` signing is equally valid over plain HTTP,
/// though the request body and credentials travel unencrypted.
const SCHEME_HTTP: &str = "http://";

/// Errors returned by [`parse_url`].
///
/// Currently limited to scheme validation; path and query parsing are lenient
/// by design (unknown percent-escapes are passed through rather than rejected).
#[derive(Clone, Copy, Debug, derive_more::Display)]
pub enum ParsedUrlError {
    /// The URL does not start with `https://` or `http://`.
    #[display("URL must start with https:// or http://")]
    BadPrefix,
}

impl std::error::Error for ParsedUrlError {}

#[cfg_attr(
    feature = "tracing",
    tracing::instrument(level = "trace", skip(url), ret, err(level = "debug"))
)]
/// Parses `url` into host, canonical path, and canonical query string.
///
/// Applies a decode-then-re-encode pass to both the path and the query string:
/// each segment is `percent_decode`d first, then re-encoded with [`uri_encode`].
/// This guarantees idempotence — a pre-encoded URL like `/my%20key` stays
/// `/my%20key` rather than becoming `/my%2520key`.
pub fn parse_url(url: &str) -> exn::Result<ParsedUrl, ParsedUrlError> {
    // Retire le schéma
    let rest = url
        .strip_prefix(SCHEME_HTTPS)
        .or_else(|| url.strip_prefix(SCHEME_HTTP))
        .ok_or_raise(|| ParsedUrlError::BadPrefix)?;

    let (authority, path_and_query) = rest
        .split_once('/')
        .map_or_else(|| (rest, "/".to_owned()), |(a, pq)| (a, format!("/{pq}")));

    let host = authority.to_owned();

    let (raw_path, raw_query) = path_and_query
        .split_once('?')
        .map_or((path_and_query.as_str(), ""), |(p, q)| (p, q));

    // Canonical URI: decode pre-encoded segments then re-encode canonically,
    // so a URL like `/my%20key` doesn't become `/my%2520key`.
    let canonical_uri = raw_path
        .split('/')
        .map(|seg| uri_encode(&percent_decode(seg), true))
        .collect::<Vec<_>>()
        .join("/");

    // Canonical query string: decode each key/value before canonical encoding
    // (same reason as above), then sort by encoded name then value.
    let canonical_query = if raw_query.is_empty() {
        String::new()
    } else {
        let mut pairs: Vec<(String, String)> = raw_query
            .split('&')
            .map(|pair| {
                let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
                (
                    uri_encode(&percent_decode(k), true),
                    uri_encode(&percent_decode(v), true),
                )
            })
            .collect();
        pairs.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
        #[expect(clippy::needless_borrowed_reference, reason = "more readable")]
        pairs
            .iter()
            .map(|&(ref k, ref v)| format!("{k}={v}"))
            .collect::<Vec<_>>()
            .join("&")
    };

    Ok(ParsedUrl {
        host,
        path: canonical_uri,
        canonical_query,
    })
}

// ────────────────────────────────────────────────────────────────────────────
// URI encoding (RFC 3986 strict — même sémantique que percent-encoding)
// ────────────────────────────────────────────────────────────────────────────

#[cfg_attr(
    feature = "tracing",
    tracing::instrument(level = "trace", skip(s, encode_slash), ret)
)]
/// Percent-encodes `s` per RFC 3986, encoding `/` only when `encode_slash` is `true`.
pub fn uri_encode(s: &str, encode_slash: bool) -> String {
    let mut out = String::with_capacity(s.len());
    for byte in s.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(byte as char);
            },
            b'/' if !encode_slash => out.push('/'),
            b => {
                let _ = write!(out, "%{b:02X}");
            },
        }
    }
    out
}

// ────────────────────────────────────────────────────────────────────────────
// Percent-decoding (inverse of `uri_encode`, tolerant of malformed sequences)
// ────────────────────────────────────────────────────────────────────────────

/// Decode `%XX` escapes in-place. A `%` not followed by two hex digits is
/// passed through unchanged, matching what most HTTP clients do.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(level = "trace", skip(s), ret)
)]
pub fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        #[expect(
            clippy::indexing_slicing,
            reason = "bounds checked by the while condition"
        )]
        let b = bytes[i];
        if b == b'%' && i + 2 < bytes.len() {
            #[expect(
                clippy::indexing_slicing,
                reason = "bounds checked by the if condition"
            )]
            if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
                out.push((h << 4) | l);
                i += 3;
                continue;
            }
        }
        out.push(b);
        i += 1;
    }
    // Malformed UTF-8 sequences are passed through as lossy replacement;
    // SigV4 canonicalization operates on bytes, so `String::from_utf8_lossy`
    // is acceptable here.
    String::from_utf8_lossy(&out).into_owned()
}

/// Convert an ASCII hex digit to its 0-15 value, or `None` if not a hex digit.
const fn hex_val(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(b - b'a' + 10),
        b'A'..=b'F' => Some(b - b'A' + 10),
        _ => None,
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, reason = "test code")]
mod tests {
    use insta::assert_debug_snapshot;

    use super::*;

    // ── uri_encode ───────────────────────────────────────────────────────────

    #[test]
    fn uri_encode_unreserved_is_unchanged() {
        assert_eq!(uri_encode("a.b-c_d~e", true), "a.b-c_d~e");
    }

    #[test]
    fn uri_encode_space_and_slash() {
        assert_eq!(uri_encode("hello world", true), "hello%20world");
        assert_eq!(uri_encode("foo/bar", true), "foo%2Fbar");
        assert_eq!(uri_encode("foo/bar", false), "foo/bar");
    }

    // ── percent_decode ───────────────────────────────────────────────────────

    #[test]
    fn percent_decode_basic() {
        assert_eq!(percent_decode("hello%20world"), "hello world");
        assert_eq!(percent_decode("%25"), "%");
        assert_eq!(percent_decode("plain"), "plain");
    }

    #[test]
    fn percent_decode_malformed_passthrough() {
        assert_eq!(percent_decode("%2"), "%2");
        assert_eq!(percent_decode("%zz"), "%zz");
    }

    // ── parse_url – general ──────────────────────────────────────────────────

    #[test]
    fn parse_url_bad_prefix() {
        let err = parse_url("ftp://example.com").expect_err("should not parse");
        assert_debug_snapshot!(*err, @"BadPrefix");
    }

    #[test]
    fn parse_url_keeps_port_in_host() {
        let p = parse_url("https://localhost:9000/bucket/key").expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "localhost:9000",
            path: "/bucket/key",
            canonical_query: "",
        }
        "#);
    }

    /// Regression: a pre-encoded query must not be double-encoded.
    #[test]
    fn parse_url_preserves_preencoded_query() {
        let p = parse_url("https://host/key?prefix=foo%20bar").expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "host",
            path: "/key",
            canonical_query: "prefix=foo%20bar",
        }
        "#);
    }

    /// Regression: a pre-encoded path must not be double-encoded.
    #[test]
    fn parse_url_preserves_preencoded_path() {
        let p = parse_url("https://host/my%20key").expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "host",
            path: "/my%20key",
            canonical_query: "",
        }
        "#);
    }

    // ── parse_url – aws-sig-v4-test-suite vectors ────────────────────────────
    //
    // Each test below is named after the corresponding directory in the suite
    // and verifies canonical_uri / canonical_query against the matching .creq.

    /// `get-vanilla`: plain slash, no query.
    #[test]
    fn suite_get_vanilla() {
        let p = parse_url("https://example.amazonaws.com/").expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "example.amazonaws.com",
            path: "/",
            canonical_query: "",
        }
        "#);
    }

    /// `get-utf8`: raw UTF-8 path segment → percent-encoded canonical URI.
    #[test]
    fn suite_get_utf8() {
        // 'ሴ' is U+1234, UTF-8 bytes E1 88 B4.
        let p = parse_url("https://example.amazonaws.com/ሴ").expect("parse_url should succeed");
        assert_eq!(p.path, "/%E1%88%B4");
    }

    /// `get-unreserved`: RFC 3986 unreserved characters must NOT be encoded.
    #[test]
    fn suite_get_unreserved() {
        let p = parse_url(
            "https://example.amazonaws.com/-._~0123456789\
             ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
        )
        .expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "example.amazonaws.com",
            path: "/-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
            canonical_query: "",
        }
        "#);
    }

    /// `get-vanilla-utf8-query`: raw UTF-8 query key → percent-encoded.
    #[test]
    fn suite_get_vanilla_utf8_query() {
        let p =
            parse_url("https://example.amazonaws.com/?ሴ=bar").expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "example.amazonaws.com",
            path: "/",
            canonical_query: "%E1%88%B4=bar",
        }
        "#);
    }

    /// `get-vanilla-query-order-key-case`: keys sorted lexicographically
    /// (uppercase before lowercase because ASCII value of 'A'-'Z' < 'a'-'z').
    #[test]
    fn suite_get_vanilla_query_order_key_case() {
        let p = parse_url("https://example.amazonaws.com/?Param2=value2&Param1=value1")
            .expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "example.amazonaws.com",
            path: "/",
            canonical_query: "Param1=value1&Param2=value2",
        }
        "#);
    }

    /// `get-vanilla-query-order-key`: same key, different values sorted by value.
    #[test]
    fn suite_get_vanilla_query_order_same_key_by_value() {
        let p = parse_url("https://example.amazonaws.com/?Param1=value2&Param1=Value1")
            .expect("parse_url should succeed");
        // 'V' (0x56) < 'v' (0x76) → "Value1" sorts before "value2"
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "example.amazonaws.com",
            path: "/",
            canonical_query: "Param1=Value1&Param1=value2",
        }
        "#);
    }

    /// `get-vanilla-query-order-value`: same key, values already lowercase,
    /// sorted by value.
    #[test]
    fn suite_get_vanilla_query_order_value() {
        let p = parse_url("https://example.amazonaws.com/?Param1=value2&Param1=value1")
            .expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "example.amazonaws.com",
            path: "/",
            canonical_query: "Param1=value1&Param1=value2",
        }
        "#);
    }

    /// `get-vanilla-empty-query-key`: single query parameter, no reordering
    /// needed.
    #[test]
    fn suite_get_vanilla_empty_query_key() {
        let p = parse_url("https://example.amazonaws.com/?Param1=value1")
            .expect("parse_url should succeed");
        assert_debug_snapshot!(p, @r#"
        ParsedUrl {
            host: "example.amazonaws.com",
            path: "/",
            canonical_query: "Param1=value1",
        }
        "#);
    }
}
