use super::*;

#[test]
fn exported_signal_json_line_format() {
    let sig = ExportedSignal {
        entity_id: 42,
        signal_type: "view".to_string(),
        weight: 1.0,
        timestamp_ns: 1_700_000_000_000_000_000,
        user_id: None,
        session_id: None,
        annotation: None,
    };
    let line = sig.to_json_line();
    assert!(line.starts_with('{'));
    assert!(line.ends_with('}'));
    assert!(line.contains("\"entity_id\":42"));
    assert!(line.contains("\"signal_type\":\"view\""));
    assert!(line.contains("\"weight\":1"));
    assert!(line.contains("\"timestamp_ns\":1700000000000000000"));
    // Anonymous signals must NOT include user/session/annotation keys.
    assert!(!line.contains("user_id"));
    assert!(!line.contains("session_id"));
    assert!(!line.contains("annotation"));
}

#[test]
fn export_request_time_range_constructor() {
    let req = ExportRequest::time_range(100, 200);
    assert_eq!(req.since, Some(100));
    assert_eq!(req.until, Some(200));
    assert!(req.signal_types.is_empty());
    assert!(req.user_id.is_none());
    assert!(req.limit.is_none());
    assert_eq!(req.format, ExportFormat::JsonLines);
}

#[test]
fn export_request_signals_in_range_constructor() {
    let req = ExportRequest::signals_in_range(vec!["view".into(), "like".into()], 100, 200);
    assert_eq!(req.signal_types.len(), 2);
    assert_eq!(req.since, Some(100));
}

#[test]
fn exported_signal_json_line_weight_precision() {
    let sig = ExportedSignal {
        entity_id: 1,
        signal_type: "like".to_string(),
        weight: 2.5,
        timestamp_ns: 100,
        user_id: None,
        session_id: None,
        annotation: None,
    };
    let line = sig.to_json_line();
    assert!(line.contains("\"weight\":2.5"));
}

#[test]
fn exported_signal_json_line_with_session_context() {
    let sig = ExportedSignal {
        entity_id: 42,
        signal_type: "view".to_string(),
        weight: 1.0,
        timestamp_ns: 1234,
        user_id: Some(7),
        session_id: Some(99),
        annotation: Some("good content".to_string()),
    };
    let line = sig.to_json_line();
    assert!(line.contains("\"user_id\":7"));
    assert!(line.contains("\"session_id\":99"));
    assert!(line.contains("\"annotation\":\"good content\""));
}

#[test]
fn exported_signal_json_line_partial_context() {
    let sig = ExportedSignal {
        entity_id: 10,
        signal_type: "like".to_string(),
        weight: 1.0,
        timestamp_ns: 500,
        user_id: Some(3),
        session_id: None,
        annotation: None,
    };
    let line = sig.to_json_line();
    assert!(line.contains("\"user_id\":3"));
    assert!(!line.contains("session_id"));
    assert!(!line.contains("annotation"));
}

#[test]
fn exported_signal_json_line_escapes_control_chars() {
    // Regression: hand-rolled escaping only covered `\\` and `"`, so a newline
    // or NUL byte in an annotation emitted a literal control char that split
    // one logical record across two physical lines and produced invalid JSON.
    // serde_json must escape ALL control chars, keeping each record on one line
    // and parseable.
    let sig = ExportedSignal {
        entity_id: 7,
        // signal_type can also carry hostile bytes (e.g. "unknown:" fallback
        // names are derived, but defense-in-depth: escape it too).
        signal_type: "li\tke".to_string(),
        weight: 1.5,
        timestamp_ns: 42,
        user_id: Some(3),
        session_id: Some(9),
        // Newline AND a NUL byte — both must be escaped, not emitted raw.
        annotation: Some("line one\nline two\0tail\twith\ttabs and \"quote\" \\slash".to_string()),
    };
    let line = sig.to_json_line();

    // The record must occupy exactly one physical line: no raw newline anywhere.
    assert_eq!(
        line.lines().count(),
        1,
        "record split across multiple physical lines: {line:?}"
    );
    assert!(
        !line.contains('\n') && !line.contains('\0') && !line.contains('\t'),
        "raw control byte leaked into JSON line: {line:?}"
    );

    // It must parse as valid JSON and round-trip the annotation exactly.
    let parsed: serde_json::Value =
        serde_json::from_str(&line).expect("rendered line must be valid JSON");
    assert_eq!(parsed["entity_id"], 7);
    assert_eq!(
        parsed["annotation"],
        serde_json::Value::String(
            "line one\nline two\0tail\twith\ttabs and \"quote\" \\slash".to_string()
        )
    );
    assert_eq!(
        parsed["signal_type"],
        serde_json::Value::String("li\tke".to_string())
    );
}

#[test]
fn exported_signal_json_lines_stream_stays_one_record_per_line() {
    // Two records joined by a single '\n' separator must yield exactly two
    // parseable lines even when both carry embedded newlines/NUL bytes.
    let a = ExportedSignal {
        entity_id: 1,
        signal_type: "view".to_string(),
        weight: 1.0,
        timestamp_ns: 1,
        user_id: None,
        session_id: None,
        annotation: Some("a\nb\0c".to_string()),
    };
    let b = ExportedSignal {
        entity_id: 2,
        signal_type: "like".to_string(),
        weight: 1.0,
        timestamp_ns: 2,
        user_id: None,
        session_id: None,
        annotation: Some("x\ny\0z".to_string()),
    };
    let stream = format!("{}\n{}", a.to_json_line(), b.to_json_line());
    let lines: Vec<&str> = stream.lines().collect();
    assert_eq!(lines.len(), 2, "stream must be exactly two physical lines");
    for l in &lines {
        let _: serde_json::Value =
            serde_json::from_str(l).expect("each physical line must be valid JSON");
    }
}

/// The bounded WAL scanner holds at most `limit` events in memory even when the
/// WAL is far larger than the limit, and returns the EARLIEST-`limit` events in
/// the range (the merge step in `export_signals` re-sorts ascending and
/// truncates, so earliest-N preserves the prior output shape).
///
/// Regression: `export_signals` used to call `read_all_events`, which decoded
/// EVERY event in EVERY segment into one `Vec` before the limit applied — a
/// `limit: Some(10)` export over a multi-million-event pre-compaction WAL could
/// OOM. This proves the working set is now bounded by the limit.
#[test]
fn scan_batch_wal_bounded_caps_working_set_and_keeps_earliest() {
    use std::collections::{HashMap, HashSet};

    use crate::replication::ShardId;
    use crate::wal::{format::EventRecord, segment::segment_filename};

    // Write a WAL FAR larger than the export limit: 50 segments * 100 events =
    // 5_000 events, each timestamped by its global ordinal so "earliest" is the
    // lowest ordinals. The limit is 10, so 4_990 events must never be retained.
    const SEGMENTS: u64 = 50;
    const EVENTS_PER_SEG: u64 = 100;
    const TOTAL: u64 = SEGMENTS * EVENTS_PER_SEG;
    const LIMIT: usize = 10;

    let dir = tempfile::tempdir().unwrap();

    let mut ordinal: u64 = 0;
    for seg in 0..SEGMENTS {
        let first_seq = seg * EVENTS_PER_SEG + 1;
        let events: Vec<EventRecord> = (0..EVENTS_PER_SEG)
            .map(|_| {
                // signal_type 0, weight 1.0, timestamp = global ordinal.
                let ev = EventRecord::signal(ordinal, 0, 1.0, ordinal);
                ordinal += 1;
                ev
            })
            .collect();
        let bytes = crate::wal::format::encode_batch(&events, first_seq, first_seq).unwrap();
        let name = segment_filename(ShardId::SINGLE, first_seq);
        std::fs::write(dir.path().join(name), &bytes).unwrap();
    }
    assert_eq!(ordinal, TOTAL);

    // No type filter; map id 0 -> "view".
    let type_filter: HashSet<u8> = HashSet::new();
    let mut type_names: HashMap<u8, String> = HashMap::new();
    type_names.insert(0, "view".to_string());

    let req = ExportRequest::time_range(0, u64::MAX);
    let got = super::scan_batch_wal_bounded(dir.path(), &req, &type_filter, &type_names, LIMIT)
        .expect("bounded scan should succeed");

    // Exactly `limit` events survive — never the whole 5_000-event WAL.
    assert_eq!(
        got.len(),
        LIMIT,
        "bounded scan must cap the result at the limit"
    );

    // The survivors are the EARLIEST `limit` events (ordinals/timestamps 0..LIMIT).
    let mut timestamps: Vec<u64> = got.iter().map(|s| s.timestamp_ns).collect();
    timestamps.sort_unstable();
    let expected: Vec<u64> = (0..LIMIT as u64).collect();
    assert_eq!(
        timestamps, expected,
        "bounded scan must retain the earliest-N events, not an arbitrary window"
    );
}

/// A zero limit retains nothing and never touches the heap, even with a
/// populated WAL — the early-return guard short-circuits the scan.
#[test]
fn scan_batch_wal_bounded_zero_limit_returns_empty() {
    use std::collections::{HashMap, HashSet};

    use crate::replication::ShardId;
    use crate::wal::{format::EventRecord, segment::segment_filename};

    let dir = tempfile::tempdir().unwrap();
    let events = vec![EventRecord::signal(1, 0, 1.0, 100)];
    let bytes = crate::wal::format::encode_batch(&events, 1, 100).unwrap();
    std::fs::write(
        dir.path().join(segment_filename(ShardId::SINGLE, 1)),
        &bytes,
    )
    .unwrap();

    let req = ExportRequest::time_range(0, u64::MAX);
    let got = super::scan_batch_wal_bounded(dir.path(), &req, &HashSet::new(), &HashMap::new(), 0)
        .expect("bounded scan should succeed");
    assert!(got.is_empty(), "zero limit must retain no events");
}

#[test]
fn export_limit_exceeds_max_returns_error() {
    use crate::TidalDb;

    let db = TidalDb::builder().ephemeral().open().unwrap();
    let req = ExportRequest {
        user_id: None,
        signal_types: Vec::new(),
        since: Some(0),
        until: Some(1),
        format: ExportFormat::JsonLines,
        limit: Some(ExportRequest::MAX_EXPORT_LIMIT + 1),
    };
    let err = db.export_signals(&req).unwrap_err();
    assert!(
        err.to_string().contains("exceeds maximum"),
        "expected limit error, got: {err}"
    );
    db.close().unwrap();
}

/// An inverted time range (`since > until`) is a typed error, not a silently
/// empty export — it almost always means the caller swapped the bounds.
#[test]
fn export_inverted_time_range_returns_error() {
    use crate::TidalDb;

    let db = TidalDb::builder().ephemeral().open().unwrap();
    let req = ExportRequest {
        user_id: None,
        signal_types: Vec::new(),
        since: Some(200),
        until: Some(100),
        format: ExportFormat::JsonLines,
        limit: None,
    };
    let err = db.export_signals(&req).unwrap_err();
    assert!(
        err.to_string().contains("inverted"),
        "expected inverted-range error, got: {err}"
    );
    // Equal bounds are a valid (empty, exclusive-until) window, not an error.
    let req_equal = ExportRequest::time_range(100, 100);
    assert!(
        db.export_signals(&req_equal).is_ok(),
        "since == until must be accepted (empty exclusive window)"
    );
    db.close().unwrap();
}

/// `render_signals` for `JsonLines` emits exactly one `to_json_line` per record,
/// joined by a single `\n` with no trailing newline, and an empty input yields
/// an empty string. This is the wire shape `export_signals_jsonl` returns, and it
/// is what wires `ExportFormat::JsonLines` + `to_json_line` into a live path.
#[test]
fn render_signals_jsonlines_is_one_record_per_line() {
    let a = ExportedSignal {
        entity_id: 1,
        signal_type: "view".to_string(),
        weight: 1.0,
        timestamp_ns: 1,
        user_id: None,
        session_id: None,
        annotation: Some("a\nb".to_string()),
    };
    let b = ExportedSignal {
        entity_id: 2,
        signal_type: "like".to_string(),
        weight: 2.0,
        timestamp_ns: 2,
        user_id: Some(7),
        session_id: None,
        annotation: None,
    };

    // Empty input -> empty string (no stray newline).
    assert_eq!(super::render_signals(&[], ExportFormat::JsonLines), "");

    let rendered = super::render_signals(&[a.clone(), b.clone()], ExportFormat::JsonLines);
    assert!(
        !rendered.ends_with('\n'),
        "no trailing newline: {rendered:?}"
    );
    let lines: Vec<&str> = rendered.lines().collect();
    assert_eq!(lines.len(), 2, "exactly one physical line per record");
    assert_eq!(lines[0], a.to_json_line());
    assert_eq!(lines[1], b.to_json_line());
    for l in &lines {
        let _: serde_json::Value =
            serde_json::from_str(l).expect("each physical line must be valid JSON");
    }
}

/// The public `export_signals_jsonl` entrypoint runs the bounded scan and renders
/// the wire format, so its output is exactly the per-record `to_json_line`s of
/// `export_signals` joined by newlines. Exercises the full method against a real
/// persistent WAL (not just the renderer), proving `ExportFormat` is wired end
/// to end rather than dead-ended.
#[test]
fn export_signals_jsonl_matches_struct_export() {
    use std::time::Duration;

    use crate::TidalDb;
    use crate::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};

    let dir = tempfile::tempdir().unwrap();
    let schema = {
        let mut builder = SchemaBuilder::new();
        let _ = builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        builder.build().unwrap()
    };

    let db = TidalDb::builder()
        .with_data_dir(dir.path())
        .with_schema(schema)
        .open()
        .unwrap();
    for i in 1u64..=3 {
        db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
            .unwrap();
    }

    let req = ExportRequest::time_range(0, u64::MAX);
    let structs = db.export_signals(&req).unwrap();
    let jsonl = db.export_signals_jsonl(&req).unwrap();
    db.close().unwrap();

    assert!(!structs.is_empty(), "expected exported signals from WAL");
    let expected = super::render_signals(&structs, ExportFormat::JsonLines);
    assert_eq!(jsonl, expected);
    // Every physical line parses as JSON, one record per line.
    assert_eq!(jsonl.lines().count(), structs.len());
    for l in jsonl.lines() {
        let _: serde_json::Value = serde_json::from_str(l).expect("each line must be valid JSON");
    }
}
