//! Canonical request construction for `SigV4`.
//!
//! A dedicated type ensures the byte-exact serialisation (field order, `\n`
//! separators, trailing newline rules) is owned in one place; the `Display`
//! output feeds directly into `sign::sha256_hex` to produce the hash embedded
//! in the string-to-sign.

use std::fmt;

/// The `SigV4` canonical request, split into the five stored fields.
///
/// The spec defines seven canonical-request lines; the remaining two
/// (`canonical_headers` and `signed_headers`) are derived by
/// [`canonical_headers_str`] and [`signed_headers`] rather than stored, so
/// they stay in sync with `headers` automatically.
///
/// [`canonical_headers_str`]: CanonicalRequest::canonical_headers_str
/// [`signed_headers`]: CanonicalRequest::signed_headers
#[derive(Debug)]
pub struct CanonicalRequest {
    /// Uppercase HTTP verb (e.g. `GET`, `PUT`).
    pub method: String,
    /// Percent-encoded request path, already normalised by `parse_url`.
    pub canonical_uri: String,
    /// Sorted, percent-encoded query string produced by `parse_url`.
    pub canonical_query: String,
    /// Sorted, lowercase header map (preserves insertion order after sort)
    pub headers: Vec<(String, String)>,
    /// Lowercase hex SHA-256 of the request body.
    pub body_hash: String,
}

impl CanonicalRequest {
    #[cfg_attr(
        feature = "tracing",
        tracing::instrument(level = "trace", skip(self), ret)
    )]
    /// Serialises headers as `name:value\n` lines for the canonical request.
    ///
    /// `SigV4` mandates a trailing newline after the last header, which is why
    /// `writeln!` (not `write!`) is used for every entry; omitting it shifts
    /// the blank-line separator and invalidates the signature.
    pub fn canonical_headers_str(&self) -> impl fmt::Display {
        fmt::from_fn(move |f| {
            #[expect(clippy::pattern_type_mismatch, reason = "more readable")]
            for (k, v) in &self.headers {
                writeln!(f, "{k}:{v}")?;
            }
            Ok(())
        })
    }

    #[cfg_attr(
        feature = "tracing",
        tracing::instrument(level = "trace", skip(self), ret)
    )]
    /// Returns the signed-headers list (`name1;name2;…`) used in `Authorization`.
    pub fn signed_headers(&self) -> impl fmt::Display {
        fmt::from_fn(move |f| {
            #[expect(clippy::pattern_type_mismatch, reason = "more readable")]
            for (idx, (k, _)) in self.headers.iter().enumerate() {
                if idx != 0 {
                    f.write_str(";")?;
                }
                f.write_str(k)?;
            }
            Ok(())
        })
    }
}

impl fmt::Display for CanonicalRequest {
    #[cfg_attr(
        feature = "tracing",
        tracing::instrument(level = "trace", skip(self, f), err(level = "debug"))
    )]
    /// Emits the canonical request string consumed by `sign::sha256_hex`.
    ///
    /// The last line (`body_hash`) is written with `write!`, not `writeln!`,
    /// so there is no trailing newline — `SigV4` requires exactly six `\n`
    /// separators for seven fields; a seventh newline would silently change
    /// the hash.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "{}", self.method)?;
        writeln!(f, "{}", self.canonical_uri)?;
        writeln!(f, "{}", self.canonical_query)?;
        writeln!(f, "{}", self.canonical_headers_str())?;
        writeln!(f, "{}", self.signed_headers())?;
        write!(f, "{}", self.body_hash)?;
        Ok(())
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, reason = "test code")]
mod tests {
    use super::*;
    use crate::sign::sha256_hex;

    // ── helpers ──────────────────────────────────────────────────────────────

    const EMPTY_BODY_HASH: &str =
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";

    fn vanilla_headers() -> Vec<(String, String)> {
        vec![
            ("host".to_owned(), "example.amazonaws.com".to_owned()),
            ("x-amz-date".to_owned(), "20150830T123600Z".to_owned()),
        ]
    }

    // ── basic formatting ─────────────────────────────────────────────────────

    #[test]
    fn signed_headers_joined_with_semicolons() {
        let cr = CanonicalRequest {
            method: "GET".to_owned(),
            canonical_uri: "/".to_owned(),
            canonical_query: String::new(),
            headers: vec![
                ("host".to_owned(), "x".to_owned()),
                ("x-amz-date".to_owned(), "y".to_owned()),
            ],
            body_hash: String::new(),
        };
        assert_eq!(cr.signed_headers().to_string(), "host;x-amz-date");
    }

    // ── aws-sig-v4-test-suite vectors ─────────────────────────────────────────
    //
    // Each test builds a CanonicalRequest from the suite's .req data, calls
    // to_string(), and checks it byte-for-byte against the published .creq.
    // It then hashes the canonical request and verifies the result matches the
    // hash embedded in the corresponding .sts file — connecting all three
    // components of the signature pipeline.

    /// `get-vanilla` — plain GET /, no query, empty body.
    ///
    /// .creq sha256 = bb579772… (from .sts line 4)
    #[test]
    fn suite_get_vanilla() {
        let cr = CanonicalRequest {
            method: "GET".to_owned(),
            canonical_uri: "/".to_owned(),
            canonical_query: String::new(),
            headers: vanilla_headers(),
            body_hash: EMPTY_BODY_HASH.to_owned(),
        };
        let expected_creq = "GET\n\
            /\n\
            \n\
            host:example.amazonaws.com\n\
            x-amz-date:20150830T123600Z\n\
            \n\
            host;x-amz-date\n\
            e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
        assert_eq!(cr.to_string(), expected_creq, "canonical request mismatch");
        assert_eq!(
            sha256_hex(expected_creq.as_bytes()),
            "bb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63",
            "creq hash mismatch",
        );
    }

    /// `get-utf8` — UTF-8 path already percent-encoded by `parse_url`.
    ///
    /// .creq sha256 = 2a0a97d0… (from .sts line 4)
    #[test]
    fn suite_get_utf8() {
        let cr = CanonicalRequest {
            method: "GET".to_owned(),
            canonical_uri: "/%E1%88%B4".to_owned(),
            canonical_query: String::new(),
            headers: vanilla_headers(),
            body_hash: EMPTY_BODY_HASH.to_owned(),
        };
        let expected_creq = "GET\n\
            /%E1%88%B4\n\
            \n\
            host:example.amazonaws.com\n\
            x-amz-date:20150830T123600Z\n\
            \n\
            host;x-amz-date\n\
            e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
        assert_eq!(cr.to_string(), expected_creq, "canonical request mismatch");
        assert_eq!(
            sha256_hex(expected_creq.as_bytes()),
            "2a0a97d02205e45ce2e994789806b19270cfbbb0921b278ccf58f5249ac42102",
            "creq hash mismatch",
        );
    }

    /// `get-unreserved` — RFC 3986 unreserved chars in path, no encoding.
    ///
    /// .creq sha256 = 6a968768… (from .sts line 4)
    #[test]
    fn suite_get_unreserved() {
        let cr = CanonicalRequest {
            method: "GET".to_owned(),
            canonical_uri: "/-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
                .to_owned(),
            canonical_query: String::new(),
            headers: vanilla_headers(),
            body_hash: EMPTY_BODY_HASH.to_owned(),
        };
        let expected_creq = "GET\n\
            /-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n\
            \n\
            host:example.amazonaws.com\n\
            x-amz-date:20150830T123600Z\n\
            \n\
            host;x-amz-date\n\
            e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
        assert_eq!(cr.to_string(), expected_creq, "canonical request mismatch");
        assert_eq!(
            sha256_hex(expected_creq.as_bytes()),
            "6a968768eefaa713e2a6b16b589a8ea192661f098f37349f4e2c0082757446f9",
            "creq hash mismatch",
        );
    }

    /// `get-vanilla-query-order-key-case` — two distinct query keys, sorted.
    ///
    /// .creq sha256 = 816cd5b4… (from .sts line 4)
    #[test]
    fn suite_get_vanilla_query_order_key_case() {
        let cr = CanonicalRequest {
            method: "GET".to_owned(),
            canonical_uri: "/".to_owned(),
            canonical_query: "Param1=value1&Param2=value2".to_owned(),
            headers: vanilla_headers(),
            body_hash: EMPTY_BODY_HASH.to_owned(),
        };
        let expected_creq = "GET\n\
            /\n\
            Param1=value1&Param2=value2\n\
            host:example.amazonaws.com\n\
            x-amz-date:20150830T123600Z\n\
            \n\
            host;x-amz-date\n\
            e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
        assert_eq!(cr.to_string(), expected_creq, "canonical request mismatch");
        assert_eq!(
            sha256_hex(expected_creq.as_bytes()),
            "816cd5b414d056048ba4f7c5386d6e0533120fb1fcfa93762cf0fc39e2cf19e0",
            "creq hash mismatch",
        );
    }

    /// `post-vanilla` — POST / with empty body.
    ///
    /// .creq sha256 = 553f88c9… (from .sts line 4)
    #[test]
    fn suite_post_vanilla() {
        let cr = CanonicalRequest {
            method: "POST".to_owned(),
            canonical_uri: "/".to_owned(),
            canonical_query: String::new(),
            headers: vanilla_headers(),
            body_hash: EMPTY_BODY_HASH.to_owned(),
        };
        let expected_creq = "POST\n\
            /\n\
            \n\
            host:example.amazonaws.com\n\
            x-amz-date:20150830T123600Z\n\
            \n\
            host;x-amz-date\n\
            e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
        assert_eq!(cr.to_string(), expected_creq, "canonical request mismatch");
        assert_eq!(
            sha256_hex(expected_creq.as_bytes()),
            "553f88c9e4d10fc9e109e2aeb65f030801b70c2f6468faca261d401ae622fc87",
            "creq hash mismatch",
        );
    }
}
