//! HTTPS upload of the canonical run output.
//!
//! Why this lives next to the file-shaped [`Reporter`] trait but does not
//! implement it: every other reporter renders to a [`std::io::Write`] sink.
//! The upload "reporter" makes a network call instead, so its surface
//! diverges (it returns an [`UploadOutcome`] the caller logs, it owns a
//! `reqwest` client, and it must never fail the test run). Co-locating it
//! with the other reporters keeps discovery easy without forcing the trait
//! to grow a transport.
//!
//! Wire format: see `schemas/wire/v0.json`. The body is a small envelope
//! around the existing [`Report`] so the collector can negotiate version
//! and organization before parsing the run.
//!
//! [`Reporter`]: super::Reporter

use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};
use thiserror::Error;
use url::Url;

use super::Report;

/// Wire-protocol version advertised in both the request body and the
/// `X-Mcptest-Wire-Version` header.
///
/// New variants append. `V0` is the preview shape; `v1` lands when
/// formalizes the canonical wire protocol.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum WireVersion {
    /// Preview wire format documented in `schemas/wire/v0.json`.
    #[default]
    V0,
}

impl WireVersion {
    /// The string the wire format and HTTP header use for this version.
    pub fn as_wire_str(&self) -> &'static str {
        match self {
            WireVersion::V0 => "v0",
        }
    }
}

/// Per-upload privacy policy.
///
/// The runner will eventually attach request and response payloads to each
/// test result. Until then `redact_request_payloads` and
/// `redact_response_payloads` are wired but no-ops; the
/// `redact_run_metadata_fields` list is honoured today against the run
/// envelope so users can strip workspace-identifying values before a third
/// party sees them.
#[derive(Debug, Clone, Default)]
pub struct PrivacyPolicy {
    /// Strip MCP request bodies from each test before upload. No-op until
    /// the runner attaches payloads.
    pub redact_request_payloads: bool,
    /// Strip MCP response bodies from each test before upload. No-op until
    /// the runner attaches payloads.
    pub redact_response_payloads: bool,
    /// JSON Pointer paths (RFC 6901) into the envelope whose values should
    /// be removed before upload. Use `/run/run_id` to strip the run id,
    /// `/organization` to strip the org tag, etc.
    pub redact_run_metadata_fields: Vec<String>,
}

/// Construction-time configuration for an [`UploadReporter`].
///
/// Built by the CLI from `--upload-endpoint` and `--upload-token-env`, or
/// by an in-process integration. The bearer token is resolved from the
/// environment by the caller; this struct carries the literal string so
/// the reporter does not need to read env vars itself.
#[derive(Debug, Clone)]
pub struct UploadConfig {
    /// Collector endpoint. Must be an absolute URL. HTTPS is recommended;
    /// HTTP is permitted for local collectors.
    pub endpoint: Url,
    /// Bearer token sent verbatim in the `Authorization` header. None
    /// means no `Authorization` header is added.
    pub bearer_token: Option<String>,
    /// Optional organization tag included in the request body, useful for
    /// shared collectors that route by tenant.
    pub organization: Option<String>,
    /// Wire-format version. Today only [`WireVersion::V0`].
    pub wire_version: WireVersion,
    /// Retries on 5xx and transport errors. 2 means up to 3 attempts.
    pub max_retries: u8,
    /// Base delay for exponential backoff. Doubled per attempt.
    pub initial_retry_delay_ms: u64,
    /// Privacy policy applied before serialization.
    pub privacy: PrivacyPolicy,
}

impl UploadConfig {
    /// Build a config with conservative defaults: 2 retries, 500ms base
    /// backoff, V0 wire format, empty privacy policy.
    pub fn new(endpoint: Url) -> Self {
        Self {
            endpoint,
            bearer_token: None,
            organization: None,
            wire_version: WireVersion::default(),
            max_retries: 2,
            initial_retry_delay_ms: 500,
            privacy: PrivacyPolicy::default(),
        }
    }
}

/// Result of a single upload attempt sequence.
///
/// Never an `Err`: a failed upload must not fail the user's test run, so
/// callers always get back something they can log.
#[derive(Debug, Clone, PartialEq)]
pub enum UploadOutcome {
    /// The collector accepted the run.
    Uploaded {
        /// Run id echoed in the upload (the same `Report::run_id`).
        run_id: String,
        /// HTTP status code from the collector.
        status: u16,
        /// Wall-clock duration of all attempts.
        duration_ms: u128,
    },
    /// Collector responded with a non-2xx status. 4xx is reported on the
    /// first response; 5xx is reported after retries are exhausted.
    ClientError {
        /// HTTP status code from the collector.
        status: u16,
        /// First 1000 bytes of the response body, for diagnostics.
        body_excerpt: String,
    },
    /// Upload was skipped without being attempted, or every attempt hit a
    /// transport error.
    Skipped {
        /// Human-readable reason suitable for a warning log line.
        reason: String,
    },
}

/// Errors raised while constructing an [`UploadReporter`].
///
/// Per the contract, `send` never returns an `Err`; only construction can
/// fail, and the CLI maps these to a user-facing message before any tests
/// run.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum UploadError {
    /// `reqwest::blocking::Client::builder()` rejected the requested
    /// configuration (rustls init, etc.).
    #[error("upload client build failed: {0}")]
    ClientBuild(String),
}

/// Posts canonical reports to a configured HTTPS endpoint.
///
/// One per CLI invocation. Construction is cheap; the underlying
/// `reqwest::blocking::Client` keeps a connection pool so multiple calls
/// in the same process reuse sockets.
pub struct UploadReporter {
    client: reqwest::blocking::Client,
    config: UploadConfig,
}

impl UploadReporter {
    /// Build a new reporter with a 30s per-attempt HTTP timeout.
    pub fn new(config: UploadConfig) -> Result<Self, UploadError> {
        let client = reqwest::blocking::Client::builder()
            .timeout(Duration::from_secs(30))
            .user_agent(default_user_agent())
            .build()
            .map_err(|e| UploadError::ClientBuild(e.to_string()))?;
        Ok(Self { client, config })
    }

    /// Post `report` to the configured endpoint. Never panics, never
    /// returns `Err`; callers should log warnings on
    /// [`UploadOutcome::ClientError`] and [`UploadOutcome::Skipped`].
    pub fn send(&self, report: &Report) -> UploadOutcome {
        let body_json = match build_envelope(&self.config, report) {
            Ok(v) => v,
            Err(e) => {
                return UploadOutcome::Skipped {
                    reason: format!("serialization failed: {e}"),
                };
            }
        };
        let body_bytes = match serde_json::to_vec(&body_json) {
            Ok(b) => b,
            Err(e) => {
                return UploadOutcome::Skipped {
                    reason: format!("serialization failed: {e}"),
                };
            }
        };

        let started = Instant::now();
        let max_attempts = (self.config.max_retries as u32).saturating_add(1);
        let mut last_transport_error: Option<String> = None;

        for attempt in 0..max_attempts {
            if attempt > 0 {
                let delay_ms = self
                    .config
                    .initial_retry_delay_ms
                    .saturating_mul(1u64 << ((attempt - 1).min(20)));
                std::thread::sleep(Duration::from_millis(delay_ms));
            }
            match self.attempt(report, &body_bytes) {
                AttemptOutcome::Success { status, run_id } => {
                    return UploadOutcome::Uploaded {
                        run_id,
                        status,
                        duration_ms: started.elapsed().as_millis(),
                    };
                }
                AttemptOutcome::ClientError {
                    status,
                    body_excerpt,
                } => {
                    return UploadOutcome::ClientError {
                        status,
                        body_excerpt,
                    };
                }
                AttemptOutcome::ServerError {
                    status,
                    body_excerpt,
                } => {
                    if attempt + 1 == max_attempts {
                        return UploadOutcome::ClientError {
                            status,
                            body_excerpt,
                        };
                    }
                    continue;
                }
                AttemptOutcome::Transport { reason } => {
                    last_transport_error = Some(reason);
                    continue;
                }
            }
        }

        UploadOutcome::Skipped {
            reason: last_transport_error.unwrap_or_else(|| "no attempts were made".to_string()),
        }
    }

    /// Issue one HTTP request and classify the response. Internal helper
    /// so [`UploadReporter::send`] keeps its loop body small.
    fn attempt(&self, report: &Report, body_bytes: &[u8]) -> AttemptOutcome {
        let mut req = self
            .client
            .post(self.config.endpoint.clone())
            .header("Content-Type", "application/json")
            .header(
                "X-Mcptest-Wire-Version",
                self.config.wire_version.as_wire_str(),
            )
            .header("X-Mcptest-Run-Id", &report.run_id);
        if let Some(token) = &self.config.bearer_token {
            req = req.header("Authorization", format!("Bearer {token}"));
        }
        let resp = match req.body(body_bytes.to_vec()).send() {
            Ok(r) => r,
            Err(e) => {
                return AttemptOutcome::Transport {
                    reason: e.to_string(),
                };
            }
        };
        let status = resp.status().as_u16();
        if (200..300).contains(&status) {
            return AttemptOutcome::Success {
                status,
                run_id: report.run_id.clone(),
            };
        }
        let body = resp.text().unwrap_or_default();
        let body_excerpt = truncate_excerpt(&body, 1000);
        if (500..600).contains(&status) {
            AttemptOutcome::ServerError {
                status,
                body_excerpt,
            }
        } else {
            AttemptOutcome::ClientError {
                status,
                body_excerpt,
            }
        }
    }
}

/// Internal classification of one HTTP attempt.
enum AttemptOutcome {
    Success { status: u16, run_id: String },
    ClientError { status: u16, body_excerpt: String },
    ServerError { status: u16, body_excerpt: String },
    Transport { reason: String },
}

/// Build the JSON envelope from `report` + `config`, applying the
/// metadata redaction list.
fn build_envelope(
    config: &UploadConfig,
    report: &Report,
) -> Result<serde_json::Value, serde_json::Error> {
    let run = serde_json::to_value(report)?;
    let mut envelope = serde_json::json!({
        "wire_version": config.wire_version.as_wire_str(),
        "run": run,
        "uploaded_at": rfc3339_now(),
    });
    if let Some(org) = &config.organization {
        envelope["organization"] = serde_json::Value::String(org.clone());
    }
    for pointer in &config.privacy.redact_run_metadata_fields {
        redact_pointer(&mut envelope, pointer);
    }
    Ok(envelope)
}

/// Remove the value at `pointer` if present. Silent no-op when the path
/// is missing so users can list a generous policy without false alarms.
fn redact_pointer(value: &mut serde_json::Value, pointer: &str) {
    if pointer.is_empty() || !pointer.starts_with('/') {
        return;
    }
    let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
    if parts.is_empty() {
        return;
    }
    let (last, parents) = match parts.split_last() {
        Some(s) => s,
        None => return,
    };
    let mut cursor = value;
    for part in parents {
        let decoded = decode_pointer_token(part);
        cursor = match cursor {
            serde_json::Value::Object(map) => match map.get_mut(decoded.as_str()) {
                Some(v) => v,
                None => return,
            },
            _ => return,
        };
    }
    let decoded_last = decode_pointer_token(last);
    if let serde_json::Value::Object(map) = cursor {
        map.remove(decoded_last.as_str());
    }
}

/// Decode RFC 6901 escapes (`~1` -> `/`, `~0` -> `~`).
fn decode_pointer_token(token: &str) -> String {
    token.replace("~1", "/").replace("~0", "~")
}

/// Truncate `body` to `limit` bytes, ASCII-safe. Appends a marker when
/// truncation happens so callers can tell the excerpt is partial.
fn truncate_excerpt(body: &str, limit: usize) -> String {
    if body.len() <= limit {
        return body.to_string();
    }
    let mut end = limit;
    while end > 0 && !body.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}... [truncated]", &body[..end])
}

/// Current time as an RFC 3339 UTC string. Avoids pulling `chrono` for a
/// single timestamp; the format string matches the rest of the codebase.
fn rfc3339_now() -> String {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    let secs = now.as_secs() as i64;
    let (year, month, day, hour, minute, second) = epoch_to_civil(secs);
    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

/// Convert a Unix timestamp to a (Y, M, D, h, m, s) tuple. Based on the
/// civil-from-days algorithm from Howard Hinnant's date library. Pure
/// arithmetic, no `chrono`.
fn epoch_to_civil(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
    let days = secs.div_euclid(86_400);
    let seconds_of_day = secs.rem_euclid(86_400) as u32;
    let hour = seconds_of_day / 3600;
    let minute = (seconds_of_day % 3600) / 60;
    let second = seconds_of_day % 60;

    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
    let doe = (z - era * 146_097) as u32;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i32 + (era * 400) as i32;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = if m <= 2 { y + 1 } else { y };
    (year, m, d, hour, minute, second)
}

/// Build the User-Agent string sent with every upload.
fn default_user_agent() -> String {
    format!("mcptest/{}", crate::version::full())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn endpoint(uri: &str) -> Url {
        Url::parse(uri).unwrap()
    }

    #[test]
    fn envelope_round_trips_run_and_marks_wire_version() {
        let cfg = UploadConfig::new(endpoint("https://example.test/v0"));
        let env = build_envelope(&cfg, &Report::sample()).unwrap();
        assert_eq!(env["wire_version"], "v0");
        assert!(env["run"].is_object());
        assert!(env.get("organization").is_none());
        assert!(env["uploaded_at"].is_string());
    }

    #[test]
    fn envelope_carries_organization_when_set() {
        let mut cfg = UploadConfig::new(endpoint("https://example.test/v0"));
        cfg.organization = Some("acme-corp".into());
        let env = build_envelope(&cfg, &Report::sample()).unwrap();
        assert_eq!(env["organization"], "acme-corp");
    }

    #[test]
    fn redact_run_metadata_field_strips_pointer() {
        let mut cfg = UploadConfig::new(endpoint("https://example.test/v0"));
        cfg.privacy.redact_run_metadata_fields = vec!["/run/run_id".into()];
        let env = build_envelope(&cfg, &Report::sample()).unwrap();
        assert!(env["run"].get("run_id").is_none());
    }

    #[test]
    fn redact_pointer_handles_missing_path_silently() {
        let mut v = serde_json::json!({"a": {"b": 1}});
        redact_pointer(&mut v, "/a/missing");
        assert_eq!(v, serde_json::json!({"a": {"b": 1}}));
    }

    #[test]
    fn truncate_excerpt_appends_marker_when_long() {
        let s: String = "x".repeat(2000);
        let t = truncate_excerpt(&s, 1000);
        assert!(t.ends_with("[truncated]"));
        assert!(t.len() < 2000);
    }

    #[test]
    fn truncate_excerpt_passes_short_through() {
        let t = truncate_excerpt("hello", 1000);
        assert_eq!(t, "hello");
    }

    #[test]
    fn wire_version_default_is_v0() {
        assert_eq!(WireVersion::default().as_wire_str(), "v0");
    }

    #[test]
    fn config_defaults_to_two_retries() {
        let cfg = UploadConfig::new(endpoint("https://example.test/v0"));
        assert_eq!(cfg.max_retries, 2);
        assert_eq!(cfg.initial_retry_delay_ms, 500);
    }

    #[test]
    fn rfc3339_now_has_expected_shape() {
        let s = rfc3339_now();
        assert_eq!(s.len(), 20);
        assert!(s.ends_with('Z'));
        assert_eq!(&s[4..5], "-");
    }

    #[test]
    fn upload_reporter_builds_with_default_config() {
        let cfg = UploadConfig::new(endpoint("https://example.test/v0"));
        let reporter = UploadReporter::new(cfg);
        assert!(reporter.is_ok());
    }
}
