//! FC-69 — redaction edge case corpus.
//!
//! Locks down semantics of `cellos_core::redaction::{redact_url_credentials_for_logs,
//! redact_url_if_echoed_in_text}` for inputs the in-module unit tests do not exercise.
//!
//! The intent is *coverage*, not behavior change: every case here asserts the **current**
//! contract. If a case looks wrong on inspection, the fix belongs in a separate ticket;
//! this file marks such spots with `// FIXME(FC-69):` and asserts the status quo so the
//! suite stays green.
//!
//! Existing in-module tests already cover:
//!   * bare `nats://host:port` pass-through
//!   * `nats://user:pass@host:port` strip
//!   * heuristic fallback for unparseable URLs with userinfo
//!   * `redact_url_if_echoed_in_text` happy path + no-op when not echoed
//!
//! BUG FOUND: none discovered while authoring this corpus. (Section "newline split"
//! does highlight a permissive heuristic — see FIXME — but the production helper does
//! NOT panic and does NOT leak credentials in any input exercised here.)

use cellos_core::redaction::{redact_url_credentials_for_logs, redact_url_if_echoed_in_text};

/// Convenience: assert that running the redactor twice equals running it once.
#[track_caller]
fn assert_idempotent(input: &str) {
    let once = redact_url_credentials_for_logs(input);
    let twice = redact_url_credentials_for_logs(&once);
    assert_eq!(
        once, twice,
        "redactor must be idempotent\n  input:  {input:?}\n  once:   {once:?}\n  twice:  {twice:?}"
    );
}

// ---------------------------------------------------------------------------
// Trivial / non-URL inputs
// ---------------------------------------------------------------------------

#[test]
fn empty_string_is_passthrough_and_does_not_panic() {
    assert_eq!(redact_url_credentials_for_logs(""), "");
    assert_idempotent("");
}

#[test]
fn plain_text_with_no_urls_is_unchanged() {
    let s = "connection refused after 3 retries (no creds here)";
    assert_eq!(redact_url_credentials_for_logs(s), s);
    assert_idempotent(s);
}

// ---------------------------------------------------------------------------
// Malformed: userinfo but no host
// ---------------------------------------------------------------------------

#[test]
fn userinfo_without_host_does_not_panic_and_does_not_leak() {
    // `Url::parse` rejects an empty host for a special scheme; falls into the heuristic
    // path which redacts via the first `@` after `://`.
    let input = "http://user:pass@/path";
    let out = redact_url_credentials_for_logs(input);
    assert!(!out.contains("user"), "leaked username: {out:?}");
    assert!(!out.contains("pass"), "leaked password: {out:?}");
    assert!(out.starts_with("http://"), "scheme mangled: {out:?}");
    assert_idempotent(input);
}

// ---------------------------------------------------------------------------
// Standard cases the unit tests don't hit
// ---------------------------------------------------------------------------

#[test]
fn https_with_explicit_port_strips_userinfo_keeps_port_and_path() {
    let input = "https://u:p@host:8443/path";
    let out = redact_url_credentials_for_logs(input);
    assert!(!out.contains("u:p"), "userinfo leaked: {out:?}");
    assert!(
        !out.contains('@'),
        "stray '@' in parsed-path output: {out:?}"
    );
    assert!(out.contains("host:8443"), "host:port lost: {out:?}");
    assert!(out.contains("/path"), "path lost: {out:?}");
    assert_idempotent(input);
}

#[test]
fn url_in_middle_of_log_line_is_redacted_via_heuristic() {
    // `Url::parse` rejects a string with surrounding text; heuristic kicks in.
    let input = "connecting to https://u:p@host/ as bot";
    let out = redact_url_credentials_for_logs(input);
    assert!(!out.contains("u:p"), "userinfo leaked: {out:?}");
    assert!(out.starts_with("connecting to "), "prefix mangled: {out:?}");
    assert!(out.ends_with(" as bot"), "suffix mangled: {out:?}");
    assert!(out.contains("host"), "host lost: {out:?}");
    assert_idempotent(input);
}

#[test]
fn url_adjacent_to_json_is_redacted_via_heuristic() {
    let input = r#"{"target":"https://u:p@host/"}"#;
    let out = redact_url_credentials_for_logs(input);
    assert!(!out.contains("u:p"), "userinfo leaked: {out:?}");
    assert!(
        out.starts_with(r#"{"target":""#),
        "JSON prefix mangled: {out:?}"
    );
    assert!(out.ends_with(r#""}"#), "JSON suffix mangled: {out:?}");
    assert!(out.contains("host"), "host lost: {out:?}");
    assert_idempotent(input);
}

#[test]
fn query_and_fragment_with_percent_encoding_preserved() {
    // url::Url normalizes some encodings; assert robust invariants rather than byte equality:
    //   - userinfo stripped
    //   - host preserved
    //   - query key and fragment marker survive
    let input = "https://u:p@host/path?k=%20%21#frag%2F";
    let out = redact_url_credentials_for_logs(input);
    assert!(!out.contains("u:p"), "userinfo leaked: {out:?}");
    assert!(out.contains("host"), "host lost: {out:?}");
    assert!(out.contains("/path"), "path lost: {out:?}");
    assert!(out.contains("?k="), "query key lost: {out:?}");
    assert!(out.contains('#'), "fragment marker lost: {out:?}");
    assert_idempotent(input);
}

// ---------------------------------------------------------------------------
// Schemes without userinfo: pass-through
// ---------------------------------------------------------------------------

#[test]
fn file_scheme_url_is_unchanged() {
    let input = "file:///etc/passwd";
    // No userinfo -> parser short-circuits to `input.to_string()`.
    assert_eq!(redact_url_credentials_for_logs(input), input);
    assert_idempotent(input);
}

#[test]
fn mailto_scheme_is_unchanged() {
    // `mailto:foo@bar` parses with empty username/no password (the `@` is part of the path
    // for an opaque scheme), so it should pass through.
    let input = "mailto:foo@bar.example";
    let out = redact_url_credentials_for_logs(input);
    // Lock current contract: address-local-part survives because it isn't URL userinfo.
    // If a future hardening pass decides mailto local-parts are PII too, that's a new
    // ticket — this test will fail loudly and force the conversation.
    assert_eq!(out, input);
    assert_idempotent(input);
}

// ---------------------------------------------------------------------------
// Internationalized domain (already punycode on the wire)
// ---------------------------------------------------------------------------

#[test]
fn punycode_idn_userinfo_stripped_host_preserved() {
    let input = "https://u:p@xn--n3h.example/";
    let out = redact_url_credentials_for_logs(input);
    assert!(!out.contains("u:p"), "userinfo leaked: {out:?}");
    assert!(
        out.contains("xn--n3h.example"),
        "punycode label was rewritten: {out:?}"
    );
    assert!(!out.contains('@'), "stray '@' after redaction: {out:?}");
    assert_idempotent(input);
}

// ---------------------------------------------------------------------------
// Two URLs in one string
// ---------------------------------------------------------------------------

#[test]
fn two_urls_back_to_back_only_one_with_userinfo_first() {
    // The whole string isn't a single valid URL -> heuristic. The heuristic redacts
    // up to the first `@` after the first `://`. The second URL has no `@`, so the
    // current contract is: only the first URL's userinfo is removed.
    let input = "https://u:p@a.example/ https://b.example/";
    let out = redact_url_credentials_for_logs(input);
    assert!(!out.contains("u:p"), "userinfo leaked: {out:?}");
    assert!(out.contains("b.example"), "second URL lost: {out:?}");
    assert!(out.contains("a.example"), "first URL host lost: {out:?}");
    assert_idempotent(input);
}

#[test]
fn two_urls_back_to_back_only_one_with_userinfo_second() {
    // Heuristic finds the first `://` (the credential-free URL) and then the next `@`
    // (which belongs to the SECOND URL). Result: the helper still scrubs `u:p`.
    // FIXME(FC-69): the redacted prefix collapses both URLs visually because the
    // heuristic is anchored on the first `://`. Not a credential leak; just ugly.
    let input = "https://b.example/ https://u:p@a.example/";
    let out = redact_url_credentials_for_logs(input);
    assert!(!out.contains("u:p"), "userinfo leaked: {out:?}");
    assert!(out.contains("a.example"), "second URL host lost: {out:?}");
    assert_idempotent(input);
}

// ---------------------------------------------------------------------------
// Newlines: lock current contract
// ---------------------------------------------------------------------------

#[test]
fn url_followed_by_newline_then_unrelated_at_sign_current_contract() {
    // The heuristic uses `str::find('@')` which DOES cross newlines. If `://` appears
    // on line 1 (without `@`) and an unrelated `@` appears on line 2, the heuristic
    // will redact across the newline boundary. This is the *current* behavior and we
    // lock it here. There is no credential leak (no userinfo to begin with), but the
    // output may look surprising.
    //
    // FIXME(FC-69): consider scoping the heuristic to a single line or to characters
    // valid in URL authority. Out of scope for FC-69.
    let input = "line1: https://example.com/\nuser@host on line2";
    let out = redact_url_credentials_for_logs(input);
    // Hard invariant: no panic, idempotent, no original credential text introduced.
    assert_idempotent(input);
    assert!(
        out.starts_with("line1: https://"),
        "prefix mangled: {out:?}"
    );
    // Soft invariant: locking current behavior — the heuristic *does* mutate the line.
    // We don't assert exact string equality on purpose; we only assert the helper
    // didn't crash and didn't invent characters not present in the input or the
    // literal "<redacted>" sentinel.
    let allowed_extra = "<redacted>";
    let stripped: String = out.replace(allowed_extra, "");
    for ch in stripped.chars() {
        assert!(
            input.contains(ch),
            "unexpected char {ch:?} introduced (out={out:?})"
        );
    }
}

#[test]
fn userinfo_on_one_line_host_on_next_does_not_panic() {
    // Pathological: a literal newline between userinfo and host. `Url::parse` rejects
    // this; heuristic still runs without panicking.
    let input = "https://u:p\n@host/path";
    let _out = redact_url_credentials_for_logs(input); // must not panic
    assert_idempotent(input);
}

// ---------------------------------------------------------------------------
// `redact_url_if_echoed_in_text` extras
// ---------------------------------------------------------------------------

#[test]
fn echoed_helper_is_idempotent_when_url_appears_multiple_times() {
    let raw = "nats://alice:hunter2@10.0.0.5:4222";
    let err = format!("first: {raw}; retry: {raw}; giving up");
    let once = redact_url_if_echoed_in_text(&err, raw);
    let twice = redact_url_if_echoed_in_text(&once, raw);
    assert_eq!(once, twice, "double-redact must equal single-redact");
    assert!(!once.contains("alice"), "{once}");
    assert!(!once.contains("hunter2"), "{once}");
    assert!(once.contains("10.0.0.5"), "{once}");
}

#[test]
fn echoed_helper_passthrough_when_raw_url_has_no_credentials() {
    // If the `raw_connection_url` itself has nothing to redact, the helper must
    // return the error text unchanged (even if the URL appears in it verbatim).
    let raw = "nats://10.0.0.5:4222";
    let err = format!("connect failed: {raw}");
    assert_eq!(redact_url_if_echoed_in_text(&err, raw), err);
}
