import sys
ROOT = r"C:/Users/decid/Documents/projects/spt-core/.worktrees/351-w5/"
edits = {}
def ed(path, old, new, count=1):
    edits.setdefault(path, []).append(("lit", old, new, count))
def tail(path, new):
    edits.setdefault(path, []).append(("tail", None, new, 1))

TW = "crates/spt-store/src/trustwarn.rs"
JT = "crates/spt-store/src/jsonltail.rs"
NS = "crates/spt/src/api/nowsignal.rs"

# ───── jsonltail: one byte read, lossy per line (2c-1, 2c-3) ─────
ed(JT, """fn trim_at(path: &Path, keep: usize) -> io::Result<()> {
    let Ok(file) = std::fs::File::open(path) else {
        return Ok(());
    };
    let lines: Vec<String> = BufReader::new(file).lines().map_while(Result::ok).collect();
    if lines.len() <= keep {
        return Ok(());
    }
    let kept = lines[lines.len() - keep..].join("\\n");
    replace_via_temp(path, format!("{kept}\\n"))
}
""", """fn trim_at(path: &Path, keep: usize) -> io::Result<()> {
    let Ok(bytes) = std::fs::read(path) else {
        return Ok(());
    };
    // Raw byte lines, never a UTF-8 line iterator: one that stops at an
    // invalid line would keep only what came before it (#346 2c-3).
    let lines: Vec<&[u8]> = raw_lines(&bytes).collect();
    if lines.len() <= keep {
        return Ok(());
    }
    let mut kept = Vec::new();
    for line in &lines[lines.len() - keep..] {
        kept.extend_from_slice(line);
        kept.push(b'\\n');
    }
    replace_via_temp(path, &kept)
}

/// The non-empty lines of `bytes`, a trailing CR dropped: what the file's
/// line bound counts.
fn raw_lines(bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
    bytes
        .split(|b| *b == b'\\n')
        .map(|line| line.strip_suffix(b"\\r").unwrap_or(line))
        .filter(|line| !line.iter().all(u8::is_ascii_whitespace))
}
""")
ed(JT, """fn replace_via_temp(path: &Path, contents: String) -> io::Result<()> {""",
   """fn replace_via_temp(path: &Path, contents: &[u8]) -> io::Result<()> {""")
ed(JT, """    replace_via_temp(path, out)
}
""", """    replace_via_temp(path, out.as_bytes())
}
""")
ed(JT, """pub fn read_at<T: DeserializeOwned>(path: &Path, tail: usize) -> Vec<T> {
    let Ok(file) = std::fs::File::open(path) else {
        return Vec::new();
    };
    let mut rows: Vec<T> = BufReader::new(file)
        .lines()
        .map_while(Result::ok)
        .filter_map(|line| serde_json::from_str(&line).ok())
        .collect();
    if rows.len() > tail {
        rows.drain(..rows.len() - tail);
    }
    rows
}
""", """pub fn read_at<T: DeserializeOwned>(path: &Path, tail: usize) -> Vec<T> {
    let (mut rows, _) = read_counted_at::<T>(path);
    if rows.len() > tail {
        rows.drain(..rows.len() - tail);
    }
    rows
}

/// Every record that parses, oldest first, AND the file's non-empty line
/// count, both from ONE read of the bytes (#346 2c-1): a caller comparing the
/// two must not see an append land between them. Each line is decoded on its
/// own and lossily, so a torn or non-UTF-8 line fails to parse and costs only
/// itself, never the lines after it (#346 2c-3).
pub fn read_counted_at<T: DeserializeOwned>(path: &Path) -> (Vec<T>, usize) {
    let Ok(bytes) = std::fs::read(path) else {
        return (Vec::new(), 0);
    };
    let mut count = 0;
    let rows = raw_lines(&bytes)
        .inspect(|_| count += 1)
        .filter_map(|line| serde_json::from_str(&String::from_utf8_lossy(line)).ok())
        .collect();
    (rows, count)
}
""")

# ───── trustwarn: one read, best-effort rewrites, session fallback ─────
ed(TW, """            receipt_session: receipt_session.map(str::to_string),""",
   """            // A session id out of shape could not be a path component, and the
            // reader would drop the record. It is recorded as unbound instead,
            // which warns every message: this fails TOWARD the warning (#346 2c-4).
            receipt_session: receipt_session.filter(|s| is_session_id_shape(s)).map(str::to_string),""")
ed(TW, """pub fn read_records_at(perch_path: &Path) -> Vec<WarningRecord> {
    crate::jsonltail::read_at::<WarningRecord>(&records_file_at(perch_path), RECORD_KEEP)
        .into_iter()
        .filter(WarningRecord::is_well_formed)
        .collect()
}
""", """pub fn read_records_at(perch_path: &Path) -> Vec<WarningRecord> {
    read_records_counted_at(perch_path).0
}

/// [`read_records_at`] plus the file's raw non-empty line count, both from ONE
/// read (#346 2c-1).
fn read_records_counted_at(perch_path: &Path) -> (Vec<WarningRecord>, usize) {
    let (rows, raw) = crate::jsonltail::read_counted_at::<WarningRecord>(&records_file_at(perch_path));
    let mut rows: Vec<WarningRecord> = rows.into_iter().filter(WarningRecord::is_well_formed).collect();
    if rows.len() > RECORD_KEEP {
        rows.drain(..rows.len() - RECORD_KEEP);
    }
    (rows, raw)
}

/// A test hook run between [`record_warning_at`]'s read and its writes, where
/// a concurrent receipt's append can land (#346 2c-1).
#[cfg(test)]
pub(crate) static AFTER_RECORD_READ: std::sync::Mutex<Option<Box<dyn FnMut() + Send>>> =
    std::sync::Mutex::new(None);
""")
ed(TW, """    let file = records_file_at(perch_path);
    let mut records = read_records_at(perch_path);
    // A line the reader dropped (malformed, or torn) still occupies the file,
    // and `append_at`'s trim counts LINES: with k such lines a full file reads
    // as k short of full, the per-peer eviction below is skipped, and the trim
    // drops the oldest line, which can be another peer's pending record
    // (#346 2b-2). So the file is first rewritten to exactly the rows read.
    let raw_lines = std::fs::read_to_string(&file)
        .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
        .unwrap_or(0);
    if raw_lines != records.len() {
        crate::jsonltail::rewrite_at(&file, &records)?;
    }
""", """    let file = records_file_at(perch_path);
    // Rows and line count from ONE read (#346 2c-1).
    let (mut records, raw_lines) = read_records_counted_at(perch_path);
    #[cfg(test)]
    if let Some(hook) = AFTER_RECORD_READ.lock().unwrap().as_mut() {
        hook();
    }
    // A line the reader dropped (malformed, or torn) still occupies the file,
    // and `append_at`'s trim counts LINES: with k such lines a full file reads
    // as k short of full, the per-peer eviction below is skipped, and the trim
    // drops the oldest line, which can be another peer's pending record
    // (#346 2b-2). So the file is first rewritten to exactly the rows read.
    //
    // EVERY REWRITE IS BEST-EFFORT, and the append below always runs (#346
    // 2c-2): a rewrite that fails (a reader holding the file on Windows makes
    // the rename fail) costs a fuller file, never the incoming warning. Every
    // arm fails toward the warning.
    if raw_lines != records.len() {
        let _ = crate::jsonltail::rewrite_at(&file, &records);
    }
""")
ed(TW, """            crate::jsonltail::rewrite_at(&records_file_at(perch_path), &records)?;""",
   """            let _ = crate::jsonltail::rewrite_at(&records_file_at(perch_path), &records);""")

# trustwarn unit tests (in its own test module): 2c-1, 2c-2, 2c-3, 2c-4
ed(TW, """#[cfg(test)]
mod tests {
    use super::*;
""", """#[cfg(test)]
mod tests {
    use super::*;

    fn tw_rec(peer: &str, op: &str, session: Option<&str>) -> WarningRecord {
        WarningRecord::new(&WarnedPeer::Endpoint(peer.to_string()), None, op, session, "x", 1)
    }

    fn peers(perch: &Path) -> Vec<String> {
        read_records_at(perch).into_iter().map(|r| r.peer).collect()
    }

    // [unit->REQ-TRUST-WARNING-NOW-SIGNAL] doyle gate W5 2c-1: a receipt that
    // lands between this receipt's read and its writes is not rewritten away.
    // The hook appends a concurrent record right after the read; the record
    // survives. Red-on-purpose: counting lines with a SECOND read sees the
    // concurrent line, mismatches the rows, and rewrites it away.
    #[test]
    fn a_concurrent_append_after_the_read_survives() {
        let tmp = tempfile::tempdir().unwrap();
        let perch = tmp.path().to_path_buf();
        record_warning_at(&perch, &tw_rec("alpha", "op-a", None)).unwrap();
        let file = records_file_at(&perch);
        let concurrent = tw_rec("carol", "op-c", None);
        *AFTER_RECORD_READ.lock().unwrap() = Some(Box::new(move || {
            crate::jsonltail::append_at(&file, &concurrent, RECORD_KEEP).unwrap();
        }));
        let result = record_warning_at(&perch, &tw_rec("bravo", "op-b", None));
        *AFTER_RECORD_READ.lock().unwrap() = None;
        assert!(result.unwrap());
        assert_eq!(peers(&perch), ["alpha", "carol", "bravo"], "the concurrent record survived");
    }

    // [unit->REQ-TRUST-WARNING-NOW-SIGNAL] doyle gate W5 2c-2: a rewrite that
    // fails does not cost the incoming warning. A malformed line forces a
    // rewrite, and every temp path it could use is a DIRECTORY, so the write
    // fails. No failure seam exists; the unique temp name is
    // `<file>.jsonl.trim.<pid>.<n>`, and nextest runs one test per process, so
    // `n` starts at 0. Red-on-purpose: `?` on the rewrite returns before the
    // append.
    #[test]
    fn a_failed_rewrite_still_records_the_warning() {
        let tmp = tempfile::tempdir().unwrap();
        let perch = tmp.path().to_path_buf();
        let file = records_file_at(&perch);
        record_warning_at(&perch, &tw_rec("alpha", "op-a", None)).unwrap();
        std::fs::OpenOptions::new().append(true).open(&file).unwrap();
        {
            use std::io::Write;
            let mut f = std::fs::OpenOptions::new().append(true).open(&file).unwrap();
            f.write_all(b"not json\\n").unwrap();
        }
        for n in 0..256 {
            let blocked = file.with_extension(format!("jsonl.trim.{}.{n}", std::process::id()));
            std::fs::create_dir_all(&blocked).unwrap();
        }
        let result = record_warning_at(&perch, &tw_rec("bravo", "op-b", None));
        assert!(result.unwrap(), "the warning is recorded");
        assert!(peers(&perch).contains(&"bravo".to_string()), "{:?}", peers(&perch));
    }

    // [unit->REQ-TRUST-WARNING-NOW-SIGNAL] doyle gate W5 2c-3: a torn,
    // non-UTF-8 line costs only itself. A row, a `\\xff` line and a row all
    // survive the next receipt. Red-on-purpose: a UTF-8 line iterator stops at
    // the torn line and the rewrite keeps only the row before it.
    #[test]
    fn a_torn_non_utf8_line_costs_only_itself() {
        let tmp = tempfile::tempdir().unwrap();
        let perch = tmp.path().to_path_buf();
        let file = records_file_at(&perch);
        let mut bytes = serde_json::to_vec(&tw_rec("alpha", "op-a", None)).unwrap();
        bytes.extend_from_slice(b"\\n\\xff\\xfe{torn\\n");
        bytes.extend_from_slice(&serde_json::to_vec(&tw_rec("carol", "op-c", None)).unwrap());
        bytes.push(b'\\n');
        std::fs::write(&file, bytes).unwrap();
        record_warning_at(&perch, &tw_rec("bravo", "op-b", None)).unwrap();
        assert_eq!(peers(&perch), ["alpha", "carol", "bravo"]);
    }

    // [unit->REQ-TRUST-WARNING-CADENCE] doyle gate W5 2c-4: a receipt session
    // out of shape is recorded as unbound, so the record still reads back and
    // warns, rather than being written and then dropped by the reader.
    // Red-on-purpose: keeping the raw session writes a record nobody reads.
    #[test]
    fn an_out_of_shape_session_records_as_unbound() {
        let tmp = tempfile::tempdir().unwrap();
        let perch = tmp.path().to_path_buf();
        let rec = tw_rec("alpha", "op-a", Some("sess:with:colons"));
        assert_eq!(rec.receipt_session, None);
        record_warning_at(&perch, &rec).unwrap();
        assert_eq!(peers(&perch), ["alpha"], "the record reads back");
        assert_eq!(tw_rec("beta", "op-b", Some("sess-ok")).receipt_session.as_deref(), Some("sess-ok"));
    }
""")

ok = True
out = {}
for path, lst in edits.items():
    full = ROOT + path
    raw = open(full, 'rb').read()
    crlf = b'\r\n' in raw
    s = raw.decode('utf-8')
    conv = (lambda t: t.replace('\n', '\r\n')) if crlf else (lambda t: t)
    for kind, a, b, count in lst:
        o, n = conv(a), conv(b)
        c = s.count(o)
        if c != count:
            print(f"FAIL {path}: expected {count} got {c}: {a[:80]!r}"); ok = False; continue
        s = s.replace(o, n)
    out[full] = s.encode('utf-8')
if not ok:
    print("NOTHING WRITTEN"); sys.exit(1)
for full, b in out.items():
    open(full, 'wb').write(b)
    print("wrote", full, b.count(b'\r\n'), b.count(b'\n'))
