//! Source-map construction for the emitter (debugging track; ADR 0103).
//!
//! The emitter is a one-way string pipe: lowering appends text and discards the
//! `Span` every AST node carries. This module is the position-tracking sidecar
//! that recovers the link. It records **checkpoints** — `(byte offset into the
//! generated buffer, source id, source span)` triples — at statement, match-arm,
//! and declaration boundaries, and resolves them into a source-map v3 document
//! once the buffer is complete.
//!
//! The design is line-level and statement-anchored (ADR 0103 D1). A checkpoint
//! marks "every generated line from here until the next checkpoint maps to this
//! source span" — the nearest-enclosing-statement rule (D2).
//!
//! **v0.70 — spliced bodies and multiple sources.** Free-function bodies lower
//! straight into the main buffer, so their byte offsets are correct. Service /
//! agent handler bodies and test-case bodies lower into a *local* buffer that is
//! later spliced into the module, so their offsets must be **rebased at the
//! splice**. [`SourceMapBuilder::merge`] does that, line-anchored so it is correct
//! for both verbatim (`push_str`) and line-by-line (indent-prepended) splices —
//! both preserve line structure. A test module aggregates several `.bynk` files,
//! so the builder carries a list of `sources` and each checkpoint a source id.

use bynk_syntax::span::{Span, line_col};

/// Accumulates source-map checkpoints during emission. Lives behind a `RefCell`
/// on `LowerCtx` so the deep lowering chain and the declaration loop can both
/// record without fighting the borrow checker. A *sub*-builder (one per spliced
/// body) records against its local buffer, then is [`merge`](Self::merge)d into
/// the module builder at the splice offset.
#[derive(Debug, Default)]
pub(crate) struct SourceMapBuilder {
    /// `(name, text)` per source file referenced by this map. Index 0 is the
    /// primary source (the file being emitted); test modules add more.
    sources: Vec<(String, String)>,
    /// `(generated byte offset, source id, source span)`, in record order — which
    /// is *not* guaranteed sorted once `merge` injects a spliced body's
    /// checkpoints, so `to_v3` sorts by generated line.
    checkpoints: Vec<(usize, usize, Span)>,
}

impl SourceMapBuilder {
    pub(crate) fn new() -> Self {
        Self::default()
    }

    /// Register a source file, returning its id (index into `sources`). Idempotent
    /// by name, so a test module that registers the same `.bynk` file for several
    /// cases reuses one id. The first registered source is the primary one
    /// (`record` targets it).
    pub(crate) fn add_source(&mut self, name: impl Into<String>, text: impl Into<String>) -> usize {
        let name = name.into();
        if let Some(i) = self.sources.iter().position(|(n, _)| *n == name) {
            return i;
        }
        self.sources.push((name, text.into()));
        self.sources.len() - 1
    }

    /// Record that generated text from `byte_offset` onward originates at `span`
    /// in the **primary** source (id 0), until the next checkpoint. Callers pass
    /// `out.len()` before appending the statement's text. Sub-builders use this;
    /// `merge` re-tags the source id.
    pub(crate) fn record(&mut self, byte_offset: usize, span: Span) {
        if let Some(last) = self.checkpoints.last_mut()
            && last.0 == byte_offset
            && last.1 == 0
        {
            last.2 = span;
            return;
        }
        self.checkpoints.push((byte_offset, 0, span));
    }

    /// Merge a sub-builder's body checkpoints into this module builder, rebased to
    /// the spliced body's position and re-tagged to `source_id` (v0.70).
    ///
    /// `sub_text` is the body's local buffer; `out` is the module buffer *after*
    /// the body has been spliced into it; `base_byte` is `out.len()` captured
    /// *before* the splice (where the body's first line starts in `out`). The
    /// merge is **line-anchored**: a checkpoint on body line *k* maps to module
    /// line `base_line + k`, so it is correct whether the splice appended the body
    /// verbatim (handlers) or rebuilt it line-by-line with indentation (tests) —
    /// both preserve line structure. Column is irrelevant: maps are line-level.
    pub(crate) fn merge(
        &mut self,
        sub: &SourceMapBuilder,
        sub_text: &str,
        out: &str,
        base_byte: usize,
        source_id: usize,
    ) {
        let sub_starts = generated_line_starts(sub_text);
        let out_starts = generated_line_starts(out);
        let base_line = line_of_offset(&out_starts, base_byte);
        for &(off, _sub_src, span) in &sub.checkpoints {
            let body_line = line_of_offset(&sub_starts, off);
            let out_line = base_line + body_line;
            if out_line < out_starts.len() {
                self.checkpoints
                    .push((out_starts[out_line], source_id, span));
            }
        }
    }

    /// Serialise to a source-map v3 JSON document over the registered `sources`.
    /// `generated` is the finished buffer; `generated_file` is the `file` field.
    /// Returns `None` when nothing resolves (no sources, or no in-range checkpoint).
    pub(crate) fn to_v3(&self, generated: &str, generated_file: &str) -> Option<String> {
        if self.sources.is_empty() {
            return None;
        }
        let line_starts = generated_line_starts(generated);
        // Resolve each checkpoint to (generated line, source id, source line, col),
        // all 0-based. Drop any whose span falls outside its source's text (a
        // defensive guard for multi-file aggregation).
        let mut resolved: Vec<(usize, usize, usize, usize)> = Vec::new();
        for &(offset, src, span) in &self.checkpoints {
            let Some((_, text)) = self.sources.get(src) else {
                continue;
            };
            if span.start > text.len() {
                continue;
            }
            let gen_line = line_of_offset(&line_starts, offset);
            let (sl, sc) = line_col(text, span.start);
            resolved.push((gen_line, src, sl - 1, sc - 1));
        }
        if resolved.is_empty() {
            return None;
        }
        // Stable sort by generated line: `merge` injects body checkpoints out of
        // record order, and the forward pass below needs ascending lines. For ties
        // the later-recorded (more specific) checkpoint stays last and wins.
        resolved.sort_by_key(|r| r.0);

        let total_lines = line_starts.len();
        let first_line = resolved[0].0;

        let mut mappings = String::new();
        for _ in 0..first_line {
            mappings.push(';');
        }
        // Previous-segment state for VLQ delta encoding (source id, source line,
        // source column persist across lines; generated column is always 0 here).
        let (mut prev_src, mut prev_sl, mut prev_sc) = (0i64, 0i64, 0i64);
        let mut ri = 0usize;
        for line in first_line..total_lines {
            if line > first_line {
                mappings.push(';');
            }
            while ri + 1 < resolved.len() && resolved[ri + 1].0 <= line {
                ri += 1;
            }
            let (_, src, sl, sc) = resolved[ri];
            let (src, sl, sc) = (src as i64, sl as i64, sc as i64);
            vlq_encode(0, &mut mappings); // generated column 0
            vlq_encode(src - prev_src, &mut mappings);
            vlq_encode(sl - prev_sl, &mut mappings);
            vlq_encode(sc - prev_sc, &mut mappings);
            prev_src = src;
            prev_sl = sl;
            prev_sc = sc;
        }

        let names = self
            .sources
            .iter()
            .map(|(n, _)| json_string(n))
            .collect::<Vec<_>>()
            .join(",");
        let contents = self
            .sources
            .iter()
            .map(|(_, t)| json_string(t))
            .collect::<Vec<_>>()
            .join(",");
        Some(format!(
            "{{\"version\":3,\"file\":{},\"sources\":[{}],\"sourcesContent\":[{}],\"names\":[],\"mappings\":{}}}",
            json_string(generated_file),
            names,
            contents,
            json_string(&mappings),
        ))
    }
}

/// Byte offset of the start of each generated line (line 0 starts at 0).
fn generated_line_starts(s: &str) -> Vec<usize> {
    let mut starts = vec![0usize];
    for (i, b) in s.bytes().enumerate() {
        if b == b'\n' {
            starts.push(i + 1);
        }
    }
    starts
}

/// 0-based generated line containing `offset`, by binary search over line starts.
fn line_of_offset(line_starts: &[usize], offset: usize) -> usize {
    match line_starts.binary_search(&offset) {
        Ok(i) => i,
        Err(i) => i - 1,
    }
}

/// Base64 VLQ-encode a single signed value, appending to `out`.
fn vlq_encode(value: i64, out: &mut String) {
    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut v = if value < 0 {
        ((-value) as u64) << 1 | 1
    } else {
        (value as u64) << 1
    };
    loop {
        let mut digit = (v & 0b11111) as usize;
        v >>= 5;
        if v != 0 {
            digit |= 0b100000;
        }
        out.push(B64[digit] as char);
        if v == 0 {
            break;
        }
    }
}

/// Minimal JSON string escaping — enough for paths, source text, and the
/// `mappings` (which is pure ASCII). Avoids a serde dependency for one field.
pub(crate) fn json_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

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

    fn build(generated: &str, b: &SourceMapBuilder) -> String {
        b.to_v3(generated, "x.ts").unwrap()
    }

    /// Pull the `mappings` string back out of the hand-built JSON document.
    fn extract_mappings(json: &str) -> String {
        let key = "\"mappings\":\"";
        let start = json.find(key).unwrap() + key.len();
        let rest = &json[start..];
        rest[..rest.find('"').unwrap()].to_string()
    }

    /// Decode each generated line's `(source_id, source_line)` (0-based), for the
    /// single-segment-per-line maps this builder emits.
    fn decode(mappings: &str) -> Vec<Option<(i64, i64)>> {
        const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
        let dec = |s: &str| -> Vec<i64> {
            let mut out = Vec::new();
            let (mut shift, mut acc) = (0i64, 0i64);
            for &c in s.as_bytes() {
                let d = B64.iter().position(|&b| b == c).unwrap() as i64;
                acc += (d & 0b11111) << shift;
                if d & 0b100000 != 0 {
                    shift += 5;
                } else {
                    out.push(if acc & 1 == 1 { -(acc >> 1) } else { acc >> 1 });
                    shift = 0;
                    acc = 0;
                }
            }
            out
        };
        let (mut src, mut sl) = (0i64, 0i64);
        let mut lines = Vec::new();
        for seg in mappings.split(';') {
            if seg.is_empty() {
                lines.push(None);
                continue;
            }
            let f = dec(seg); // [genCol, srcIdxDelta, srcLineDelta, srcCol]
            src += f[1];
            sl += f[2];
            lines.push(Some((src, sl)));
        }
        lines
    }

    #[test]
    fn vlq_round_numbers() {
        let mut s = String::new();
        for (v, want) in [(0, "A"), (1, "C"), (-1, "D"), (16, "gB")] {
            s.clear();
            vlq_encode(v, &mut s);
            assert_eq!(s, want);
        }
    }

    #[test]
    fn single_source_maps_each_line_to_nearest_checkpoint() {
        let source = "header\nstmtA\nstmtB\n";
        let off_a = source.find("stmtA").unwrap();
        let off_b = source.find("stmtB").unwrap();
        let generated = "g0\ng1\ng2\ng3\n";
        let g2 = generated.find("g2").unwrap();

        let mut b = SourceMapBuilder::new();
        b.add_source("x.bynk", source);
        b.record(0, Span::new(off_a, off_a + 5)); // gen line 0 -> src line 1 (0-based)
        b.record(g2, Span::new(off_b, off_b + 5)); // gen line 2 -> src line 2

        let json = build(generated, &b);
        assert!(json.contains("\"sources\":[\"x.bynk\"]"));
        let lines = decode(&extract_mappings(&json));
        // All from source 0; lines 1,1,2,2,(trailing 2).
        assert_eq!(
            lines,
            vec![
                Some((0, 1)),
                Some((0, 1)),
                Some((0, 2)),
                Some((0, 2)),
                Some((0, 2))
            ]
        );
    }

    #[test]
    fn merge_line_anchors_a_spliced_body_under_a_second_source() {
        // Module buffer: a declaration line from source 0, then a spliced body
        // (two lines) from source 1, indent-prepended (line-by-line splice).
        let mod_src = "service api\n";
        let body_src = "  let a = 1\n  let b = 2\n"; // the .bynk body, source 1
        let off_a = body_src.find("let a").unwrap();
        let off_b = body_src.find("let b").unwrap();

        let mut module = SourceMapBuilder::new();
        let s0 = module.add_source("mod.bynk", mod_src);
        assert_eq!(s0, 0);
        // The module's own declaration checkpoint (source 0, line 1).
        module.record(0, Span::new(0, 7));

        // Lower the body into a local buffer with a sub-builder.
        let sub_text = "const a = 1;\nconst b = 2;\n";
        let mut sub = SourceMapBuilder::new();
        sub.record(0, Span::new(off_a, off_a + 5)); // body line 0 -> body src line 0
        let bl1 = sub_text.find("const b").unwrap();
        sub.record(bl1, Span::new(off_b, off_b + 5)); // body line 1 -> body src line 1

        // Splice the body into the module buffer line-by-line with indentation
        // (mirrors the real test-body splice), capturing base before the splice.
        let mut out = String::from("export const api = {};\n"); // gen line 0
        let base = out.len();
        for line in sub_text.lines() {
            out.push_str("    ");
            out.push_str(line);
            out.push('\n');
        }
        // gen line 1 <- body line 0, gen line 2 <- body line 1.
        let s1 = module.add_source("body.bynk", body_src);
        module.merge(&sub, sub_text, &out, base, s1);

        let json = module.to_v3(&out, "mod.ts").unwrap();
        assert!(json.contains("\"sources\":[\"mod.bynk\",\"body.bynk\"]"));
        let lines = decode(&extract_mappings(&json));
        // gen 0 -> (src0, line0); gen 1 -> (src1, line0); gen 2 -> (src1, line1).
        assert_eq!(lines[0], Some((0, 0)));
        assert_eq!(lines[1], Some((1, 0)));
        assert_eq!(lines[2], Some((1, 1)));
    }

    #[test]
    fn empty_builder_serialises_to_none() {
        assert!(SourceMapBuilder::new().to_v3("x\n", "x.ts").is_none());
    }
}
