//! Turn a raw capture into a repository-safe fixture, auditably.
//!
//! Raw captures are forensic evidence and may carry session material. This
//! produces something committable from one, and the governing rule is that
//! sanitization is **explicit and reported**, never a silent byte substitution
//! followed by a claim of equivalence.
//!
//! # What is preserved
//!
//! Everything except identified sensitive values: Fire2 headers, component and
//! command IDs, message types and numbers, user index, frame ordering,
//! connection identity, payload structure, and every non-sensitive protocol
//! value.
//!
//! # What is replaced, and how
//!
//! Only the tags in [`SENSITIVE_TAGS`], and only their values. Replacement is
//! **length-preserving**: a redacted string occupies exactly as many bytes as
//! the original, so the TDF length varint, the payload length and the Fire2
//! header are all unchanged and the sanitized frame is the same size as the
//! captured one. That is asserted per frame — a size change means the
//! sanitizer is wrong, and it fails rather than emitting a subtly different
//! conversation.
//!
//! # What this costs
//!
//! A sanitized frame is NOT byte-identical to the wire: that is the entire
//! point, and it is why the raw capture is kept separately. Every difference is
//! enumerated in the report.

use openfut_protocol_blaze::fire2::Frame;
use openfut_protocol_blaze::heat2::{self, Struct, Value};

use crate::capture::Record;

/// Tags whose values are session-specific or credential-shaped.
///
/// A deliberately small, named list. Anything not here is preserved, so a
/// reviewer can see exactly what was touched — and a new sensitive field shows
/// up as un-redacted rather than being silently caught by a broad pattern.
pub const SENSITIVE_TAGS: &[&str] = &[
    "KEY",  // Blaze session key (LoginResponse.SESS.KEY, UserAuthenticated.KEY)
    "AUTH", // auth code / token (LoginRequest.AUTH, GetAuthTokenResponse.AUTH)
    "SESS", // telemetry session echo, when carried as a string
    "MAIL", // account email
    "PML",  // parental email
];

/// One replacement that was made.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Redaction {
    pub seq: u64,
    pub conn_id: u64,
    /// Dotted path to the member, e.g. `SESS.KEY`.
    pub path: String,
    pub kind: &'static str,
    pub original_len: usize,
    pub replacement_len: usize,
}

#[derive(Debug, Default)]
pub struct Report {
    pub frames_in: usize,
    pub frames_out: usize,
    pub frames_redacted: usize,
    pub redactions: Vec<Redaction>,
    /// Frames whose payload did not decode as TDF and were passed through.
    pub undecodable: Vec<u64>,
}

/// A length-preserving, obviously-synthetic replacement.
fn filler(len: usize) -> String {
    const PATTERN: &[u8] = b"REDACTED_";
    (0..len)
        .map(|i| PATTERN[i % PATTERN.len()] as char)
        .collect()
}

fn redact_struct(s: &Struct, prefix: &str, rec: &Record, out: &mut Vec<Redaction>) -> Struct {
    let mut fields = Vec::with_capacity(s.len());
    for (tag, value) in s.iter() {
        let label = tag.to_label();
        let path = if prefix.is_empty() {
            label.clone()
        } else {
            format!("{prefix}.{label}")
        };

        let new_value = match value {
            Value::Struct(inner) => Value::Struct(redact_struct(inner, &path, rec, out)),
            Value::List { elem, items } => Value::List {
                elem: *elem,
                items: items
                    .iter()
                    .map(|it| match it {
                        Value::Struct(inner) => {
                            Value::Struct(redact_struct(inner, &path, rec, out))
                        }
                        other => other.clone(),
                    })
                    .collect(),
            },
            Value::String(v) if SENSITIVE_TAGS.contains(&label.as_str()) && !v.is_empty() => {
                // Byte length, not char count: the wire length is what must be
                // preserved, and the filler is ASCII so the two coincide.
                let n = v.len();
                out.push(Redaction {
                    seq: rec.seq,
                    conn_id: rec.conn_id,
                    path: path.clone(),
                    kind: "string",
                    original_len: n,
                    replacement_len: n,
                });
                Value::String(filler(n))
            }
            other => other.clone(),
        };
        fields.push((*tag, new_value));
    }
    Struct { fields }
}

/// Sanitize one captured frame. Returns the new frame bytes.
pub fn sanitize_record(rec: &Record, report: &mut Report) -> Result<Vec<u8>, String> {
    let (frame, _) = Frame::parse(&rec.frame).map_err(|e| format!("seq {}: {e}", rec.seq))?;

    if frame.payload.is_empty() {
        return Ok(rec.frame.clone());
    }
    let body = match heat2::decode(&frame.payload) {
        Ok(b) => b,
        Err(_) => {
            // Not decodable as TDF. Passed through unchanged and reported, so a
            // reader knows it was never inspected rather than assuming it was
            // checked and found clean.
            report.undecodable.push(rec.seq);
            return Ok(rec.frame.clone());
        }
    };

    let before = report.redactions.len();
    let redacted = redact_struct(&body, "", rec, &mut report.redactions);
    if report.redactions.len() == before {
        // Nothing sensitive: emit the original bytes, not a re-encoding, so
        // untouched frames stay byte-identical to the wire.
        return Ok(rec.frame.clone());
    }
    report.frames_redacted += 1;

    let mut out = Frame::new(
        frame.header.component,
        frame.header.command,
        frame.header.msg_num,
        frame.header.msg_type,
        heat2::encode(&redacted),
    );
    out.header.user_index = frame.header.user_index;
    out.header.options = frame.header.options;
    let bytes = out.encode();

    // Length preservation is the contract that keeps a sanitized capture a
    // faithful conversation. If it does not hold, something is wrong with the
    // replacement and emitting the result would be worse than failing.
    if bytes.len() != rec.frame.len() {
        return Err(format!(
            "seq {}: sanitized frame is {} bytes, original {} — replacement was not \
             length-preserving",
            rec.seq,
            bytes.len(),
            rec.frame.len()
        ));
    }
    Ok(bytes)
}

/// A captured record paired with its sanitized frame bytes.
pub type Sanitized = (Record, Vec<u8>);

/// Sanitize a whole capture.
pub fn sanitize(records: &[Record]) -> Result<(Vec<Sanitized>, Report), String> {
    let mut report = Report {
        frames_in: records.len(),
        ..Default::default()
    };
    let mut out = Vec::with_capacity(records.len());
    for rec in records {
        let bytes = sanitize_record(rec, &mut report)?;
        out.push((rec.clone(), bytes));
    }
    report.frames_out = out.len();
    Ok((out, report))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::capture::Direction;
    use openfut_protocol_blaze::fire2::MsgType;

    fn rec(seq: u64, body: &Struct) -> Record {
        Record {
            conn_id: 1,
            seq,
            ts_ms: 0,
            dir: Direction::Tx,
            frame: Frame::new(0x0001, 0x000A, 7, MsgType::Reply, heat2::encode(body)).encode(),
        }
    }

    #[test]
    fn redacts_a_session_key_without_changing_frame_size() {
        let body = Struct::new()
            .with(
                "KEY",
                Value::String("a-real-looking-session-key-0123456789".into()),
            )
            .with("UID", Value::Int(33068179));
        let r = rec(1, &body);

        let mut report = Report::default();
        let out = sanitize_record(&r, &mut report).unwrap();

        assert_eq!(out.len(), r.frame.len(), "framing must be preserved");
        assert_eq!(report.redactions.len(), 1);
        assert_eq!(report.redactions[0].path, "KEY");
        assert_eq!(
            report.redactions[0].original_len,
            report.redactions[0].replacement_len
        );

        let (f, _) = Frame::parse(&out).unwrap();
        let decoded = heat2::decode(&f.payload).unwrap();
        let key = decoded.get("KEY").and_then(Value::as_str).unwrap();
        assert!(!key.contains("real-looking"), "secret must be gone");
        assert!(
            key.starts_with("REDACTED"),
            "and obviously synthetic: {key}"
        );
        // Non-sensitive values survive untouched.
        assert_eq!(decoded.get("UID").and_then(Value::as_int), Some(33068179));
    }

    #[test]
    fn redacts_nested_and_reports_the_path() {
        let inner = Struct::new()
            .with("KEY", Value::String("secret-key-value".into()))
            .with("MAIL", Value::String("someone@example.com".into()));
        let body = Struct::new()
            .with("SESS", Value::Struct(inner))
            .with("ANON", Value::Int(0));

        let mut report = Report::default();
        let out = sanitize_record(&rec(2, &body), &mut report).unwrap();

        let paths: Vec<&str> = report.redactions.iter().map(|r| r.path.as_str()).collect();
        assert!(paths.contains(&"SESS.KEY"), "{paths:?}");
        assert!(paths.contains(&"SESS.MAIL"), "{paths:?}");
        assert!(!String::from_utf8_lossy(&out).contains("someone@example.com"));
        assert!(!String::from_utf8_lossy(&out).contains("secret-key-value"));
    }

    #[test]
    fn redacts_inside_lists() {
        let body = Struct::new().with(
            "NLST",
            Value::List {
                elem: openfut_protocol_blaze::heat2::TypeId::Struct,
                items: vec![Value::Struct(
                    Struct::new().with("AUTH", Value::String("token-abcdef".into())),
                )],
            },
        );
        let mut report = Report::default();
        let out = sanitize_record(&rec(3, &body), &mut report).unwrap();
        assert_eq!(report.redactions.len(), 1, "list members must be reached");
        assert!(!String::from_utf8_lossy(&out).contains("token-abcdef"));
    }

    #[test]
    fn frames_with_nothing_sensitive_stay_byte_identical() {
        let body = Struct::new()
            .with("UID", Value::Int(1))
            .with("CO", Value::String("US".into()));
        let r = rec(4, &body);
        let mut report = Report::default();
        let out = sanitize_record(&r, &mut report).unwrap();
        assert_eq!(out, r.frame, "untouched frames keep the exact wire bytes");
        assert!(report.redactions.is_empty());
        assert_eq!(report.frames_redacted, 0);
    }

    #[test]
    fn empty_payloads_pass_through() {
        let r = Record {
            conn_id: 1,
            seq: 5,
            ts_ms: 0,
            dir: Direction::Rx,
            frame: Frame::new(9, 2, 1, MsgType::Message, vec![]).encode(),
        };
        let mut report = Report::default();
        assert_eq!(sanitize_record(&r, &mut report).unwrap(), r.frame);
    }

    #[test]
    fn undecodable_payloads_are_passed_through_and_flagged() {
        let r = Record {
            conn_id: 1,
            seq: 6,
            ts_ms: 0,
            dir: Direction::Rx,
            frame: Frame::new(9, 2, 1, MsgType::Message, vec![0xFF; 8]).encode(),
        };
        let mut report = Report::default();
        let out = sanitize_record(&r, &mut report).unwrap();
        assert_eq!(out, r.frame);
        assert_eq!(
            report.undecodable,
            vec![6],
            "must be reported, not silently kept"
        );
    }

    #[test]
    fn ordering_and_identity_survive_a_whole_capture() {
        let recs: Vec<Record> = (0..5)
            .map(|i| {
                rec(
                    i,
                    &Struct::new().with("KEY", Value::String(format!("key-{i}-padding"))),
                )
            })
            .collect();
        let (out, report) = sanitize(&recs).unwrap();
        assert_eq!(report.frames_in, 5);
        assert_eq!(report.frames_out, 5);
        assert_eq!(report.frames_redacted, 5);
        for (i, (r, _)) in out.iter().enumerate() {
            assert_eq!(r.seq, i as u64, "sequence order preserved");
            assert_eq!(r.conn_id, 1);
        }
    }

    #[test]
    fn every_sensitive_tag_is_actually_reachable() {
        // Guards against a tag being listed but never matched — e.g. a typo.
        for tag in SENSITIVE_TAGS {
            let body = Struct::new().with(tag, Value::String("sensitive-value".into()));
            let mut report = Report::default();
            let out = sanitize_record(&rec(9, &body), &mut report).unwrap();
            assert_eq!(report.redactions.len(), 1, "tag {tag} was not redacted");
            assert!(
                !String::from_utf8_lossy(&out).contains("sensitive-value"),
                "tag {tag} leaked"
            );
        }
    }
}
