#![doc=include_str!("../README.md")]
#[cfg(all(feature = "native-tls", feature = "rustls"))]
compile_error!("features `openssl` and `rustls` are mutually exclusive — enable only one");

/// AWS credential resolution: profile files, env vars, and CLI flags.
///
/// Reads `~/.aws/credentials` and `~/.aws/config` (or overrides via
/// `AWS_SHARED_CREDENTIALS_FILE` / `AWS_CONFIG_FILE`) and applies the standard
/// AWS SDK precedence: CLI flag > env var > profile file > built-in default.
mod profile;
/// Produces the canonical-request string consumed by `sign::sha256_hex`.
///
/// Encapsulated so the byte-exact format (field order, newlines, case) required
/// by `SigV4` is enforced in one place and cannot drift at call sites.
mod request;
/// `SigV4` signing primitives: HMAC-SHA256 helpers and multi-step key derivation.
///
/// The four-step HMAC chain (`kDate → kRegion → kService → kSigning`) is mandated
/// by the `SigV4` spec to bind credentials to a specific date, region, and service.
mod sign;
/// Co-derives the two timestamp strings required by `SigV4`.
///
/// Both formats are produced from the same `OffsetDateTime` to guarantee they
/// never drift apart; a mismatch between `x-amz-date` and the credential scope
/// silently produces an invalid signature.
mod timestamp;
/// URL parsing and RFC 3986 percent-encoding, without the `url` crate.
///
/// The `url` crate is deliberately avoided to keep the dependency tree minimal;
/// see `CLAUDE.md` for the project policy on external dependencies.
mod url;

use std::{collections::BTreeMap, fs, path::PathBuf, str::FromStr as _, time::Duration};

use clap::Parser;
use exn::{OptionExt as _, ResultExt as _, bail};
use time::OffsetDateTime;
use ureq::{
    Agent,
    http::{HeaderName, HeaderValue, Method, Request, StatusCode},
};

use crate::{
    request::CanonicalRequest,
    sign::{
        ALGORITHM, HDR_AUTHORIZATION, HDR_CONTENT_SHA256, HDR_DATE, HDR_HOST, HDR_SECURITY_TOKEN,
        REQUEST_SUFFIX, SigningKey, sha256_hex,
    },
    timestamp::timestamps,
    url::{parse_url, uri_encode},
};

/// Service name that uses single URI encoding; all others require a second pass.
///
/// S3 is the only `SigV4` service that signs the single-encoded canonical URI.
/// Every other service requires the path to be percent-encoded a second time
/// before signing (the `SigV4` double-encoding rule applied in `main`).
const SERVICE_S3: &str = "s3";

// ────────────────────────────────────────────────────────────────────────────
// CLI
// ────────────────────────────────────────────────────────────────────────────

/// Minimal S3 / `SigV4` HTTP client.
///
/// Credentials can be passed as arguments, but should be read from the environment.
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
    /// HTTP method, uppercased before use in the canonical request.
    ///
    /// `SigV4` requires an uppercase verb; `main` converts the value before
    /// constructing [`CanonicalRequest`], so `get` and `GET` both work here.
    #[arg(short, long, default_value = "GET")]
    method: String,

    /// Inline request body.
    ///
    /// Mutually exclusive with `--body-file`; the bytes are SHA-256-hashed for
    /// `x-amz-content-sha256` regardless of HTTP method.
    #[arg(long, default_value = "")]
    body: String,

    /// Path to a file whose raw bytes form the request body.
    ///
    /// Read as bytes (not text) so binary objects hash correctly for
    /// `x-amz-content-sha256`; mutually exclusive with `--body`.
    #[arg(long, value_name = "FILE", conflicts_with = "body")]
    body_file: Option<PathBuf>,

    /// AWS region embedded in the credential scope.
    ///
    /// Signing binds the request to a specific region; AWS rejects the
    /// signature if this value doesn't match the region of the endpoint.
    /// Falls back to the active profile's region, then `us-east-1`.
    #[arg(long, env = "AWS_REGION")]
    region: Option<String>,

    /// AWS service identifier, used in the credential scope and URI canonicalisation.
    ///
    /// When the service is anything other than `s3`, `main` applies a second
    /// percent-encoding pass to the canonical URI (`SigV4` double-encoding rule).
    #[arg(long, default_value = SERVICE_S3)]
    service: String,

    /// AWS access key ID, emitted verbatim in `Credential=` of the `Authorization` header.
    ///
    /// Falls back to the active AWS profile when omitted.
    #[arg(long, env = "AWS_ACCESS_KEY_ID")]
    access_key: Option<String>,

    /// AWS secret access key, used only as the HMAC seed in [`SigningKey::derive`].
    ///
    /// Never transmitted; keeping it out of headers is a core security property
    /// of `SigV4`. Falls back to the active AWS profile when omitted.
    #[arg(long, env = "AWS_SECRET_ACCESS_KEY")]
    secret_key: Option<String>,

    /// STS session token for temporary credentials.
    ///
    /// When present, triggers a signed `x-amz-security-token` header; absent
    /// for long-term IAM credentials. Falls back to the active profile's token.
    #[arg(long, env = "AWS_SESSION_TOKEN")]
    session_token: Option<String>,

    /// AWS CLI profile to load credentials and region from.
    ///
    /// Reads `~/.aws/credentials` and `~/.aws/config`; defaults to `default`
    /// when neither this flag nor `AWS_PROFILE` is set.
    #[arg(long, env = "AWS_PROFILE")]
    profile: Option<String>,

    /// Additional signed headers in `Key: Value` format (repeatable).
    ///
    /// Merged into the signed-header map after the built-in headers (`host`,
    /// `x-amz-date`, `x-amz-content-sha256`); a duplicate key overrides the
    /// built-in value and still appears in `SignedHeaders`.
    #[arg(short = 'H', long = "header")]
    headers: Vec<String>,

    /// Print canonical request, string-to-sign and sent headers
    #[arg(short, long)]
    verbose: bool,

    /// Target URL (path-style or virtual-hosted S3 endpoint).
    ///
    /// The host component determines the `Host` header, which is always signed;
    /// virtual-hosted and path-style URLs produce different canonical requests.
    url: String,
}

// ────────────────────────────────────────────────────────────────────────────
// main
// ────────────────────────────────────────────────────────────────────────────

/// Errors that abort the CLI, structured for `exn`-style propagation.
///
/// A typed enum (rather than `anyhow`) lets each call site attach diagnostic
/// context via `or_raise(|| …)` without heap-allocating a trait object.
#[derive(Clone, Debug, derive_more::Display)]
enum MainError {
    /// Failed to read the body file at `path`.
    #[display("reading {path:?}")]
    Read {
        /// Preserved verbatim so the error message shows exactly which path was tried.
        path: PathBuf,
    },
    /// Timestamp formatting failed; practically unreachable.
    ///
    /// Both format strings use only `[year][month][day]…` tokens that `time`
    /// always supports on an `OffsetDateTime`.
    TimeStamp,
    /// The URL could not be parsed; only `https://` and `http://` are accepted.
    ParseUrl {
        /// Preserved verbatim so the user can spot a typo or unsupported scheme.
        url: String,
    },
    /// A `-H` header string was not in `Key: Value` format.
    #[display("Header format must be 'Key: Value', got: {header}")]
    Header {
        /// Preserved verbatim so the user can spot a missing `:` or extra whitespace.
        header: String,
    },
    /// The header name was rejected by the HTTP library.
    #[display("invalid header name: {name}")]
    InvalidHeaderName {
        /// Passed through from ureq to help diagnose non-ASCII bytes or invalid
        /// HTTP/1.1 token characters.
        name: String,
    },
    /// The header value was rejected by the HTTP library.
    #[display("invalid header value: {value}")]
    InvalidHeaderValue {
        /// Passed through from ureq to help diagnose control characters or other
        /// bytes rejected by HTTP/1.1 field-value rules.
        value: String,
    },
    /// The HTTP method was rejected after uppercasing.
    ///
    /// `main` uppercases the input before calling `Method::from_str`, so a
    /// lowercase letter alone will not trigger this; it fires only for strings
    /// that are not valid HTTP tokens (e.g. spaces, control characters).
    #[display("Invalid HTTP method: {method}")]
    Method {
        /// The pre-uppercased string, so the user sees exactly what was passed.
        method: String,
    },
    /// The HTTP request could not be built or sent.
    ///
    /// Intentionally merges ureq request-builder errors and network/TLS errors
    /// under one variant; both are unrecoverable for a CLI.
    #[display("HTTP request failed")]
    Request,
    /// Reading the response body as UTF-8 text failed.
    ///
    /// `ureq` streams the body via `read_to_string`; this can fail on I/O errors
    /// or non-UTF-8 bytes in the server response.
    #[display("reading response body")]
    Body,
    /// The server returned a non-2xx status.
    #[display("Request failed with status {status}")]
    HttpError {
        /// The only field because the body has already been printed to stdout;
        /// it lets the caller distinguish retryable (5xx) from client (4xx) errors.
        status: StatusCode,
    },
    /// Credential resolution failed (profile file missing, profile not found, etc.).
    ///
    /// The underlying [`profile::ProfileError`] is preserved in the `exn` cause chain
    /// and appears in the `Debug` output automatically.
    #[display("credential resolution failed")]
    Credentials,
}
impl std::error::Error for MainError {}

// ────────────────────────────────────────────────────────────────────────────
// Pipeline helpers
// ────────────────────────────────────────────────────────────────────────────

/// Installs a pretty-printing `tracing` subscriber configured from `RUST_LOG`.
///
/// No-op when the `tracing` feature is disabled; kept separate so `main` stays
/// focused on the `SigV4` pipeline rather than observability setup.
fn setup_tracing() {
    #[cfg(feature = "tracing")]
    {
        use std::io::{IsTerminal as _, stdout};

        use tracing_subscriber::{
            EnvFilter, Layer as _, Registry,
            fmt::{self, format::FmtSpan},
            layer::SubscriberExt as _,
            util::SubscriberInitExt as _,
        };

        Registry::default()
            .with(
                fmt::layer()
                    .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
                    .event_format(fmt::format().pretty())
                    .with_ansi(stdout().is_terminal())
                    .with_filter(EnvFilter::from_default_env()),
            )
            .init();
    }
}

/// Reads the request body from `--body-file` or from the inline `--body`.
///
/// File I/O is isolated here so read failures surface as [`MainError::Read`]
/// with the offending path before any `SigV4` work begins.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(level = "trace", skip(args), ret, err(level = "debug"))
)]
fn read_body(args: &Args) -> exn::Result<Vec<u8>, MainError> {
    args.body_file.as_ref().map_or_else(
        || Ok(args.body.clone().into_bytes()),
        |path| fs::read(path).or_raise(|| MainError::Read { path: path.clone() }),
    )
}

/// Builds the lexicographically-sorted, lowercase-keyed header set to sign.
///
/// `BTreeMap` provides the `SigV4`-required ordering for free and lets
/// user-supplied `-H` headers override the built-ins (`host`,
/// `x-amz-content-sha256`, `x-amz-date`, `x-amz-security-token`) via
/// last-insert-wins semantics.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        level = "trace",
        skip(extra_headers, session_token, host, body_hash, amzdate),
        ret,
        err(level = "debug")
    )
)]
fn build_signed_header_map(
    extra_headers: &[String],
    session_token: Option<&str>,
    host: &str,
    body_hash: &str,
    amzdate: &str,
) -> exn::Result<Vec<(String, String)>, MainError> {
    let mut headers: BTreeMap<String, String> = BTreeMap::new();
    headers.insert(HDR_HOST.to_owned(), host.to_owned());
    headers.insert(HDR_CONTENT_SHA256.to_owned(), body_hash.to_owned());
    headers.insert(HDR_DATE.to_owned(), amzdate.to_owned());
    if let Some(tok) = session_token {
        headers.insert(HDR_SECURITY_TOKEN.to_owned(), tok.to_owned());
    }
    for h in extra_headers {
        let (k, v) = h
            .split_once(':')
            .ok_or_raise(|| MainError::Header { header: h.clone() })?;
        headers.insert(k.trim().to_lowercase(), v.trim().to_owned());
    }
    Ok(headers.into_iter().collect())
}

/// Validates each signed header through ureq's parser and appends `Authorization`.
///
/// Skips `host` because `ureq` derives it from the request URI; the
/// `Authorization` header is appended here so it travels alongside the signed
/// set. Errors fire only for bytes that passed the raw `-H` parser but violate
/// HTTP/1.1 token rules (control characters, non-ASCII).
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        level = "trace",
        skip(headers, authorization),
        ret,
        err(level = "debug")
    )
)]
fn to_http_header_pairs(
    headers: Vec<(String, String)>,
    authorization: &str,
) -> exn::Result<Vec<(HeaderName, HeaderValue)>, MainError> {
    let mut pairs: Vec<(HeaderName, HeaderValue)> = Vec::new();
    for (ref k, ref v) in headers {
        if k == HDR_HOST {
            continue;
        }
        pairs.push((
            HeaderName::from_str(k)
                .or_raise(|| MainError::InvalidHeaderName { name: k.clone() })?,
            HeaderValue::from_str(v)
                .or_raise(|| MainError::InvalidHeaderValue { value: v.clone() })?,
        ));
    }
    pairs.push((
        HeaderName::from_str(HDR_AUTHORIZATION).or_raise(|| MainError::InvalidHeaderName {
            name: HDR_AUTHORIZATION.to_owned(),
        })?,
        HeaderValue::from_str(authorization).or_raise(|| MainError::InvalidHeaderValue {
            value: authorization.to_owned(),
        })?,
    ));
    Ok(pairs)
}

/// Builds the `ureq` agent with a 30-second global timeout and the selected TLS provider.
///
/// Isolates the per-feature `tls_config` plumbing so `main` doesn't need the
/// surrounding `#[cfg]` guards; TLS backend mutual-exclusion is enforced at
/// the crate root.
fn build_agent() -> Agent {
    #[cfg_attr(
        all(not(feature = "native-tls"), not(feature = "rustls")),
        expect(unused_mut, reason = "no tls")
    )]
    let mut config = Agent::config_builder().timeout_global(Some(Duration::from_secs(30)));

    #[cfg(feature = "rustls")]
    {
        config = config.tls_config(
            ureq::tls::TlsConfig::builder()
                .provider(ureq::tls::TlsProvider::Rustls)
                .build(),
        );
    }
    #[cfg(feature = "native-tls")]
    {
        config = config.tls_config(
            ureq::tls::TlsConfig::builder()
                .provider(ureq::tls::TlsProvider::NativeTls)
                .build(),
        );
    }

    config.build().new_agent()
}

// ────────────────────────────────────────────────────────────────────────────
// Entry point
// ────────────────────────────────────────────────────────────────────────────

/// Signs and sends an AWS `SigV4` HTTP request to an S3-compatible endpoint.
///
/// Pipeline: parse URL → hash body → co-derive timestamps → build canonical
/// request → assemble string-to-sign → derive signing key → sign → send via
/// `ureq`. Errors propagate as `exn::Result` because the lint policy prohibits
/// `panic` and `process::exit`.
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(), ret))]
#[cfg_attr(not(feature = "tracing"), expect(clippy::print_stdout, reason = "cli"))]
#[expect(clippy::print_stderr, reason = "cli")]
fn main() -> exn::Result<(), MainError> {
    setup_tracing();

    let args = Args::parse();

    // ── Credential resolution ─────────────────────────────────────────────────
    let creds = profile::resolve(
        args.profile.as_deref(),
        args.access_key.as_deref(),
        args.secret_key.as_deref(),
        args.session_token.as_deref(),
        args.region.as_deref(),
    )
    .or_raise(|| MainError::Credentials)?;

    // ── Body + hash ──────────────────────────────────────────────────────────
    let body = read_body(&args)?;
    let body_hash = sha256_hex(&body);

    // ── Timestamps ───────────────────────────────────────────────────────────
    let now = OffsetDateTime::now_utc();
    let (amzdate, datestamp) = timestamps(now).or_raise(|| MainError::TimeStamp)?;

    // ── URL ──────────────────────────────────────────────────────────────────
    let parsed = parse_url(&args.url).or_raise(|| MainError::ParseUrl {
        url: args.url.clone(),
    })?;

    // SigV4 requires double URI-encoding of the canonical URI for every
    // service except S3 (which signs the single-encoded path).
    let canonical_uri = if args.service == SERVICE_S3 {
        parsed.path.clone()
    } else {
        uri_encode(&parsed.path, false)
    };

    // ── Canonical request ────────────────────────────────────────────────────
    let headers_to_sign = build_signed_header_map(
        &args.headers,
        creds.session_token.as_deref(),
        &parsed.host,
        &body_hash,
        &amzdate,
    )?;

    let cr = CanonicalRequest {
        method: args.method.to_uppercase(),
        canonical_uri,
        canonical_query: parsed.canonical_query,
        headers: headers_to_sign.clone(),
        body_hash,
    };
    let cr_str = cr.to_string();

    if args.verbose {
        eprintln!("=== Canonical Request ===\n{cr_str}\n");
    }

    // ── String-to-sign + signature + Authorization ──────────────────────────
    let credential_scope = format!(
        "{datestamp}/{}/{}/{REQUEST_SUFFIX}",
        creds.region, args.service
    );
    let string_to_sign = format!(
        "{ALGORITHM}\n{amzdate}\n{credential_scope}\n{}",
        sha256_hex(cr_str.as_bytes())
    );

    if args.verbose {
        eprintln!("=== String to Sign ===\n{string_to_sign}\n");
    }

    let signing_key =
        SigningKey::derive(&creds.secret_key, &datestamp, &creds.region, &args.service);
    let signature = signing_key.sign_hex(&string_to_sign);

    let signed_headers = cr.signed_headers();
    let authorization = format!(
        "{ALGORITHM} Credential={access_key}/{credential_scope}, \
         SignedHeaders={signed_headers}, Signature={signature}",
        access_key = creds.access_key
    );

    // ── HTTP send ────────────────────────────────────────────────────────────
    let signed_header_pairs = to_http_header_pairs(headers_to_sign, &authorization)?;

    if args.verbose {
        eprintln!("=== Request Headers ===");
        #[expect(clippy::pattern_type_mismatch, reason = "more readable")]
        for (k, v) in &signed_header_pairs {
            eprintln!("  {}: {}", k, v.to_str().unwrap_or("<binary>"));
        }
        eprintln!();
    }

    let agent = build_agent();

    let method = Method::from_str(&args.method.to_uppercase()).or_raise(|| MainError::Method {
        method: args.method,
    })?;

    let mut req_builder = Request::builder().method(method).uri(&args.url);
    for (k, v) in signed_header_pairs {
        req_builder = req_builder.header(k, v);
    }
    let req = req_builder.body(body).or_raise(|| MainError::Request)?;

    let mut resp = agent.run(req).or_raise(|| MainError::Request)?;

    let status = resp.status();
    eprintln!("HTTP {status}");

    if args.verbose {
        for (k, v) in resp.headers() {
            eprintln!("{}: {}", k, v.to_str().unwrap_or("<binary>"));
        }
        eprintln!();
    }

    let text = resp
        .body_mut()
        .read_to_string()
        .or_raise(|| MainError::Body)?;
    print!("{text}");

    if !status.is_success() {
        bail!(MainError::HttpError { status })
    }

    Ok(())
}
