use rand::{Rng, RngExt};

const GARBLE_CHARS: &[u8] =
  b"!@#$%^&*()_+-=[]{}|;:,.<>?`~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

const ANCHOR_ROW: f64 = 10.0;
const ANCHOR_COL: f64 = 19.0;
const CORE_ROW_OFFSET: f64 = 7.0;
const CHAR_ASPECT_RATIO: f64 = 0.5;

pub const REVEAL_SPEED: f64 = 0.6;
pub const GARBLE_FRINGE: f64 = 6.0;

const CORE_GROW_SPEED: f64 = 0.2;
const CORE_MAX_RADIUS: f64 = 2.0;
const CORE_HOLD_TICKS: usize = 70;
const CROSSFADE_TICKS: usize = 20;
pub const DISSOLVE_TILE_COLS: usize = 6;
pub const DISSOLVE_TILE_ROWS: usize = 5;
const DISSOLVE_TICKS_PER_TILE: usize = 1;
const DISSOLVE_GARBLE_TICKS_MIN: usize = 4;
const DISSOLVE_GARBLE_TICKS_MAX: usize = 8;
const DISSOLVE_START_JITTER: usize = 4;
const DISSOLVE_BOUNDARY_NOISE: usize = 6;

const REVEAL_NOISE: f64 = 8.0;
const DISSOLVE_CHAR_NOISE_TICKS: usize = 2;

pub struct DistanceMap {
  distances: Vec<Vec<f64>>,
  pub max_distance: f64,
}

pub fn build_distance_map(lines: &[&str]) -> DistanceMap {
  let mut max_distance: f64 = 0.0;
  let distances: Vec<Vec<f64>> = lines
    .iter()
    .enumerate()
    .map(|(row, line)| {
      line
        .chars()
        .enumerate()
        .map(|(col, ch)| {
          if ch == ' ' {
            return f64::INFINITY;
          }
          let d = euclidean_distance(row as f64, col as f64);
          if d > max_distance {
            max_distance = d;
          }
          d
        })
        .collect()
    })
    .collect();

  DistanceMap {
    distances,
    max_distance,
  }
}

fn euclidean_distance(row: f64, col: f64) -> f64 {
  let dr = row - ANCHOR_ROW;
  let dc = (col - ANCHOR_COL) * CHAR_ASPECT_RATIO;
  (dr * dr + dc * dc).sqrt()
}

fn core_distance(row: f64, col: f64) -> f64 {
  let dr = row - (ANCHOR_ROW + CORE_ROW_OFFSET);
  let dc = (col - ANCHOR_COL) * CHAR_ASPECT_RATIO;
  (dr * dr + dc * dc).sqrt()
}

pub fn distance_at(map: &DistanceMap, row: usize, col: usize) -> f64 {
  map.distances[row][col]
}

pub fn radial_reveal_frame<R: Rng>(
  lines: &[&str],
  map: &DistanceMap,
  garble_radius: f64,
  resolve_radius: f64,
  rng: &mut R,
) -> Vec<String> {
  lines
    .iter()
    .enumerate()
    .map(|(row, line)| {
      line
        .chars()
        .enumerate()
        .map(|(col, ch)| {
          if ch == ' ' {
            return ' ';
          }
          let noise = rng.random_range(-REVEAL_NOISE..REVEAL_NOISE);
          let d = distance_at(map, row, col) + noise;
          if d <= resolve_radius {
            ch
          } else if d <= garble_radius {
            random_garble_char(rng)
          } else {
            ' '
          }
        })
        .collect()
    })
    .collect()
}

pub fn entrance_frame<R: Rng>(
  lines: &[&str],
  map: &DistanceMap,
  tick: usize,
  rng: &mut R,
) -> Vec<String> {
  let core_end = core_total_ticks();
  let reveal_start = core_end.saturating_sub(CROSSFADE_TICKS);
  let in_core = tick < core_end;
  let in_reveal = tick >= reveal_start;

  lines
    .iter()
    .enumerate()
    .map(|(row, line)| {
      line
        .chars()
        .enumerate()
        .map(|(col, ch)| {
          let reveal_char = if in_reveal {
            reveal_char_at(ch, row, col, tick, reveal_start, map, rng)
          } else {
            ' '
          };

          if reveal_char != ' ' {
            return reveal_char;
          }

          if in_core {
            core_char_at(row, col, tick, rng)
          } else {
            ' '
          }
        })
        .collect()
    })
    .collect()
}

fn core_char_at<R: Rng>(row: usize, col: usize, tick: usize, rng: &mut R) -> char {
  let radius = core_radius(tick);
  let d = core_distance(row as f64, col as f64);
  let noise = rng.random_range(-REVEAL_NOISE..REVEAL_NOISE);
  if d + noise <= radius {
    random_garble_char(rng)
  } else {
    ' '
  }
}

fn reveal_char_at<R: Rng>(
  ch: char,
  row: usize,
  col: usize,
  tick: usize,
  reveal_start: usize,
  map: &DistanceMap,
  rng: &mut R,
) -> char {
  if ch == ' ' {
    return ' ';
  }
  let reveal_tick = tick - reveal_start;
  let garble_radius = CORE_MAX_RADIUS + reveal_tick as f64 * REVEAL_SPEED;
  let resolve_radius = garble_radius - GARBLE_FRINGE;
  let noise = rng.random_range(-REVEAL_NOISE..REVEAL_NOISE);
  let d = distance_at(map, row, col) + noise;
  if d <= resolve_radius {
    ch
  } else if d <= garble_radius {
    random_garble_char(rng)
  } else {
    ' '
  }
}

pub fn is_entrance_complete(map: &DistanceMap, tick: usize) -> bool {
  let reveal_start = core_total_ticks().saturating_sub(CROSSFADE_TICKS);
  if tick < reveal_start {
    return false;
  }
  let reveal_tick = tick - reveal_start;
  let garble_radius = CORE_MAX_RADIUS + reveal_tick as f64 * REVEAL_SPEED;
  let resolve_radius = garble_radius - GARBLE_FRINGE;
  resolve_radius >= map.max_distance
}

fn core_total_ticks() -> usize {
  core_grow_ticks() + CORE_HOLD_TICKS
}

fn core_grow_ticks() -> usize {
  (CORE_MAX_RADIUS / CORE_GROW_SPEED).ceil() as usize
}

fn core_radius(tick: usize) -> f64 {
  let grow_ticks = core_grow_ticks();
  if tick < grow_ticks {
    tick as f64 * CORE_GROW_SPEED
  } else {
    CORE_MAX_RADIUS
  }
}

pub struct DissolveState {
  tile_assignments: Vec<Vec<usize>>,
  tile_starts: Vec<usize>,
  tile_garble_durations: Vec<usize>,
  pub total_ticks: usize,
}

pub fn build_dissolve_state<R: Rng>(lines: &[&str], rng: &mut R) -> DissolveState {
  let rows = lines.len();
  let cols = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
  let tile_cols = cols.div_ceil(DISSOLVE_TILE_COLS);
  let tile_rows = rows.div_ceil(DISSOLVE_TILE_ROWS);
  let tile_count = tile_cols * tile_rows;

  let tile_assignments = build_noisy_tile_assignments(lines, tile_cols, tile_count, rng);
  let tile_starts = build_tile_starts(tile_count, rng);
  let tile_garble_durations = build_tile_garble_durations(tile_count, rng);

  let total_ticks = tile_starts
    .iter()
    .zip(tile_garble_durations.iter())
    .map(|(start, dur)| start + dur + DISSOLVE_CHAR_NOISE_TICKS)
    .max()
    .unwrap_or(0);

  DissolveState {
    tile_assignments,
    tile_starts,
    tile_garble_durations,
    total_ticks,
  }
}

fn build_noisy_tile_assignments<R: Rng>(
  lines: &[&str],
  tile_cols: usize,
  tile_count: usize,
  rng: &mut R,
) -> Vec<Vec<usize>> {
  lines
    .iter()
    .enumerate()
    .map(|(row, line)| {
      line
        .chars()
        .enumerate()
        .map(|(col, _)| {
          let base = tile_index(row, col, tile_cols);
          if !is_tile_boundary(row, col) {
            return base;
          }
          let offset: i32 =
            rng.random_range(-(DISSOLVE_BOUNDARY_NOISE as i32)..=(DISSOLVE_BOUNDARY_NOISE as i32));
          (base as i32 + offset).clamp(0, tile_count as i32 - 1) as usize
        })
        .collect()
    })
    .collect()
}

fn is_tile_boundary(row: usize, col: usize) -> bool {
  let row_pos = row % DISSOLVE_TILE_ROWS;
  let col_pos = col % DISSOLVE_TILE_COLS;
  row_pos == 0
    || row_pos == DISSOLVE_TILE_ROWS - 1
    || col_pos == 0
    || col_pos == DISSOLVE_TILE_COLS - 1
}

fn build_tile_starts<R: Rng>(tile_count: usize, rng: &mut R) -> Vec<usize> {
  let mut order: Vec<usize> = (0..tile_count).collect();
  shuffle(&mut order, rng);

  let mut starts = vec![0usize; tile_count];
  for (position, &tile_idx) in order.iter().enumerate() {
    let base = position * DISSOLVE_TICKS_PER_TILE;
    let jitter = rng.random_range(0..=DISSOLVE_START_JITTER);
    starts[tile_idx] = base.saturating_sub(jitter);
  }
  starts
}

fn build_tile_garble_durations<R: Rng>(tile_count: usize, rng: &mut R) -> Vec<usize> {
  (0..tile_count)
    .map(|_| rng.random_range(DISSOLVE_GARBLE_TICKS_MIN..=DISSOLVE_GARBLE_TICKS_MAX))
    .collect()
}

pub fn dissolve_frame<R: Rng>(
  lines: &[&str],
  state: &DissolveState,
  tick: usize,
  rng: &mut R,
) -> Vec<String> {
  lines
    .iter()
    .enumerate()
    .map(|(row, line)| {
      line
        .chars()
        .enumerate()
        .map(|(col, ch)| {
          if ch == ' ' {
            return ' ';
          }
          let tile_idx = state.tile_assignments[row][col];
          let char_noise = rng.random_range(0..=DISSOLVE_CHAR_NOISE_TICKS);
          let effective_tick = tick.saturating_sub(char_noise);
          dissolve_char_from_state(ch, tile_idx, effective_tick, state, rng)
        })
        .collect()
    })
    .collect()
}

fn dissolve_char_from_state<R: Rng>(
  ch: char,
  tile_idx: usize,
  tick: usize,
  state: &DissolveState,
  rng: &mut R,
) -> char {
  let start = state.tile_starts[tile_idx];
  if tick < start {
    return ch;
  }
  let elapsed = tick - start;
  let garble_duration = state.tile_garble_durations[tile_idx];
  if elapsed < garble_duration {
    random_garble_char(rng)
  } else {
    ' '
  }
}

fn tile_index(row: usize, col: usize, tile_cols: usize) -> usize {
  let tr = row / DISSOLVE_TILE_ROWS;
  let tc = col / DISSOLVE_TILE_COLS;
  tr * tile_cols + tc
}

fn random_garble_char<R: Rng>(rng: &mut R) -> char {
  GARBLE_CHARS[rng.random_range(0..GARBLE_CHARS.len())] as char
}

fn shuffle<R: Rng>(slice: &mut [usize], rng: &mut R) {
  for i in (1..slice.len()).rev() {
    let j = rng.random_range(0..=i);
    slice.swap(i, j);
  }
}

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

  fn sample_lines() -> Vec<&'static str> {
    vec!["  ▓▓▓  ", " ▓▓▓▓▓ ", "▓▓▓▓▓▓▓", " ▓▓▓▓▓ ", "  ▓▓▓  "]
  }

  #[test]
  fn distance_map_assigns_infinity_to_spaces() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);

    assert_eq!(distance_at(&map, 0, 0), f64::INFINITY);
    assert_eq!(distance_at(&map, 0, 1), f64::INFINITY);
  }

  #[test]
  fn distance_map_computes_finite_for_visible_chars() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);

    assert!(distance_at(&map, 2, 3).is_finite());
    assert!(map.max_distance > 0.0);
  }

  #[test]
  fn radial_reveal_shows_nothing_at_zero_radius() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let frame = radial_reveal_frame(&lines, &map, 0.0, -6.0, &mut rng);

    for line in &frame {
      assert!(line.chars().all(|c| c == ' '));
    }
  }

  #[test]
  fn radial_reveal_shows_all_at_large_radius() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let frame = radial_reveal_frame(&lines, &map, 100.0, 100.0, &mut rng);

    assert_eq!(frame[2], lines[2]);
  }

  #[test]
  fn dissolve_frame_preserves_all_at_tick_zero() {
    let lines = sample_lines();
    let mut rng = rand::rng();
    let state = build_dissolve_state(&lines, &mut rng);

    let frame = dissolve_frame(&lines, &state, 0, &mut rng);

    for (original, result) in lines.iter().zip(frame.iter()) {
      for (oc, rc) in original.chars().zip(result.chars()) {
        if oc == ' ' {
          assert_eq!(rc, ' ');
        } else {
          assert_ne!(rc, ' ');
        }
      }
    }
  }

  #[test]
  fn dissolve_frame_clears_all_after_completion() {
    let lines = sample_lines();
    let mut rng = rand::rng();
    let state = build_dissolve_state(&lines, &mut rng);

    let frame = dissolve_frame(&lines, &state, state.total_ticks + 50, &mut rng);

    for line in &frame {
      assert!(line.chars().all(|c| c == ' '));
    }
  }

  #[test]
  fn dissolve_state_has_valid_total_ticks() {
    let lines = sample_lines();
    let mut rng = rand::rng();
    let state = build_dissolve_state(&lines, &mut rng);

    assert!(state.total_ticks > 0);
  }

  #[test]
  fn dissolve_state_tile_assignments_cover_grid() {
    let lines = sample_lines();
    let mut rng = rand::rng();
    let state = build_dissolve_state(&lines, &mut rng);

    assert_eq!(state.tile_assignments.len(), lines.len());
    for (row_assignments, line) in state.tile_assignments.iter().zip(lines.iter()) {
      assert_eq!(row_assignments.len(), line.chars().count());
    }
  }

  #[test]
  fn entrance_frame_shows_content_during_reveal() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let reveal_start = core_total_ticks().saturating_sub(CROSSFADE_TICKS);
    let tick = reveal_start + 200;
    let frame = entrance_frame(&lines, &map, tick, &mut rng);
    let has_non_space = frame.iter().any(|l| l.chars().any(|c| c != ' '));
    assert!(has_non_space);
  }

  #[test]
  fn entrance_frame_resolves_fully_at_large_tick() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let frame = entrance_frame(&lines, &map, 5000, &mut rng);
    assert_eq!(frame[2], lines[2]);
  }

  #[test]
  fn is_entrance_complete_false_at_start() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);

    assert!(!is_entrance_complete(&map, 0));
  }

  #[test]
  fn is_entrance_complete_true_at_large_tick() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);

    assert!(is_entrance_complete(&map, 5000));
  }

  #[test]
  fn core_distance_is_finite_for_all_positions() {
    assert!(core_distance(0.0, 0.0).is_finite());
    assert!(core_distance(10.0, 19.0).is_finite());
  }

  #[test]
  fn core_radius_grows_then_caps() {
    assert_eq!(core_radius(0), 0.0);
    let grow = core_grow_ticks();
    assert!((core_radius(grow) - CORE_MAX_RADIUS).abs() < 0.01);
    assert!((core_radius(grow + 100) - CORE_MAX_RADIUS).abs() < 0.01);
  }

  #[test]
  fn dissolve_frame_garbles_midway() {
    let lines = sample_lines();
    let mut rng = rand::rng();
    let state = build_dissolve_state(&lines, &mut rng);

    let mid = state.total_ticks / 2;
    let frame = dissolve_frame(&lines, &state, mid, &mut rng);

    let has_garble = frame.iter().any(|l| {
      l.chars()
        .any(|c| c != ' ' && !lines.iter().any(|orig| orig.contains(c)))
    });
    assert!(has_garble);
  }

  #[test]
  fn tile_index_maps_positions_to_tiles() {
    assert_eq!(tile_index(0, 0, 3), 0);
    assert_eq!(tile_index(0, DISSOLVE_TILE_COLS, 3), 1);
    assert_eq!(tile_index(DISSOLVE_TILE_ROWS, 0, 3), 3);
  }

  #[test]
  fn is_tile_boundary_detects_edges() {
    assert!(is_tile_boundary(0, 0));
    assert!(is_tile_boundary(DISSOLVE_TILE_ROWS - 1, 0));
    assert!(is_tile_boundary(0, DISSOLVE_TILE_COLS - 1));
    assert!(!is_tile_boundary(1, 1));
  }

  #[test]
  fn shuffle_produces_permutation() {
    let mut rng = rand::rng();
    let mut data: Vec<usize> = (0..10).collect();
    shuffle(&mut data, &mut rng);

    let mut sorted = data.clone();
    sorted.sort();
    assert_eq!(sorted, (0..10).collect::<Vec<_>>());
  }

  #[test]
  fn euclidean_distance_at_anchor_is_zero() {
    let d = euclidean_distance(ANCHOR_ROW, ANCHOR_COL);
    assert!(d < 0.01);
  }

  #[test]
  fn radial_reveal_garbles_in_fringe_zone() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let garble_radius = map.max_distance + 10.0;
    let resolve_radius = -100.0;
    let frame = radial_reveal_frame(&lines, &map, garble_radius, resolve_radius, &mut rng);

    let has_non_original = frame[2] != lines[2];
    assert!(has_non_original);
  }

  fn large_sample_lines() -> Vec<&'static str> {
    vec![
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
      "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
    ]
  }

  #[test]
  fn entrance_frame_core_produces_garble_near_anchor() {
    let lines = large_sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let tick = core_grow_ticks();
    let frame = entrance_frame(&lines, &map, tick, &mut rng);

    let core_row = (ANCHOR_ROW + CORE_ROW_OFFSET) as usize;
    if core_row < frame.len() {
      let has_garble = frame[core_row].chars().any(|c| c != ' ');
      assert!(has_garble);
    }
  }

  #[test]
  fn entrance_frame_crossfade_blends_core_and_reveal() {
    let lines = large_sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let reveal_start = core_total_ticks().saturating_sub(CROSSFADE_TICKS);
    let tick = reveal_start + CROSSFADE_TICKS / 2;
    let frame = entrance_frame(&lines, &map, tick, &mut rng);
    let has_content = frame.iter().any(|l| l.chars().any(|c| c != ' '));
    assert!(has_content);
  }

  #[test]
  fn entrance_before_reveal_only_has_core() {
    let lines = large_sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let reveal_start = core_total_ticks().saturating_sub(CROSSFADE_TICKS);
    let tick = reveal_start.saturating_sub(1).max(1);
    let frame = entrance_frame(&lines, &map, tick, &mut rng);
    let has_content = frame.iter().any(|l| l.chars().any(|c| c != ' '));
    assert!(has_content);
  }

  #[test]
  fn build_dissolve_state_large_grid() {
    let lines = large_sample_lines();
    let mut rng = rand::rng();
    let state = build_dissolve_state(&lines, &mut rng);

    assert!(state.total_ticks > 0);
    assert!(!state.tile_starts.is_empty());
    assert!(!state.tile_garble_durations.is_empty());
  }

  #[test]
  fn dissolve_frame_large_grid_at_various_ticks() {
    let lines = large_sample_lines();
    let mut rng = rand::rng();
    let state = build_dissolve_state(&lines, &mut rng);

    let early = dissolve_frame(&lines, &state, 1, &mut rng);
    assert_eq!(early.len(), lines.len());

    let late = dissolve_frame(&lines, &state, state.total_ticks + 100, &mut rng);
    for line in &late {
      assert!(line.chars().all(|c| c == ' '));
    }
  }

  #[test]
  fn random_garble_char_produces_valid_chars() {
    let mut rng = rand::rng();
    for _ in 0..100 {
      let ch = random_garble_char(&mut rng);
      assert!(GARBLE_CHARS.contains(&(ch as u8)));
    }
  }

  #[test]
  fn build_tile_starts_covers_all_tiles() {
    let mut rng = rand::rng();
    let starts = build_tile_starts(10, &mut rng);
    assert_eq!(starts.len(), 10);
  }

  #[test]
  fn build_tile_garble_durations_in_range() {
    let mut rng = rand::rng();
    let durations = build_tile_garble_durations(10, &mut rng);
    assert_eq!(durations.len(), 10);
    for d in &durations {
      assert!(*d >= DISSOLVE_GARBLE_TICKS_MIN);
      assert!(*d <= DISSOLVE_GARBLE_TICKS_MAX);
    }
  }

  #[test]
  fn build_noisy_tile_assignments_has_boundary_noise() {
    let lines = large_sample_lines();
    let mut rng = rand::rng();
    let tile_cols = lines[0].chars().count().div_ceil(DISSOLVE_TILE_COLS);
    let tile_rows = lines.len().div_ceil(DISSOLVE_TILE_ROWS);
    let tile_count = tile_cols * tile_rows;

    let assignments = build_noisy_tile_assignments(&lines, tile_cols, tile_count, &mut rng);
    assert_eq!(assignments.len(), lines.len());
    assert_eq!(assignments[0].len(), lines[0].chars().count());
  }

  #[test]
  fn dissolve_char_from_state_before_start() {
    let mut rng = rand::rng();
    let lines = large_sample_lines();
    let state = build_dissolve_state(&lines, &mut rng);

    let max_start = *state.tile_starts.iter().max().unwrap();
    let tile_with_max = state
      .tile_starts
      .iter()
      .position(|&s| s == max_start)
      .unwrap();

    let before = dissolve_char_from_state('▓', tile_with_max, 0, &state, &mut rng);
    assert_eq!(before, '▓');
  }

  #[test]
  fn dissolve_char_from_state_during_garble() {
    let mut rng = rand::rng();
    let lines = large_sample_lines();
    let state = build_dissolve_state(&lines, &mut rng);

    let start = state.tile_starts[0];
    let during = dissolve_char_from_state('▓', 0, start + 1, &state, &mut rng);
    assert!(GARBLE_CHARS.contains(&(during as u8)));
  }

  #[test]
  fn dissolve_char_from_state_after_garble() {
    let mut rng = rand::rng();
    let lines = large_sample_lines();
    let state = build_dissolve_state(&lines, &mut rng);

    let start = state.tile_starts[0];
    let end_tick = start + state.tile_garble_durations[0] + 1;
    let after = dissolve_char_from_state('▓', 0, end_tick, &state, &mut rng);
    assert_eq!(after, ' ');
  }

  #[test]
  fn core_total_ticks_is_sum_of_grow_and_hold() {
    let total = core_total_ticks();
    assert_eq!(total, core_grow_ticks() + CORE_HOLD_TICKS);
  }

  #[test]
  fn reveal_char_at_space_returns_space() {
    let lines = sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let ch = reveal_char_at(' ', 0, 0, 100, 0, &map, &mut rng);
    assert_eq!(ch, ' ');
  }

  #[test]
  fn reveal_char_at_resolved_returns_original() {
    let lines = large_sample_lines();
    let map = build_distance_map(&lines);
    let mut rng = rand::rng();

    let ch = reveal_char_at('▓', 10, 19, 50000, 0, &map, &mut rng);
    assert_eq!(ch, '▓');
  }

  #[test]
  fn core_char_at_within_radius() {
    let mut rng = rand::rng();
    let core_row = (ANCHOR_ROW + CORE_ROW_OFFSET) as usize;
    let anchor_col = ANCHOR_COL as usize;

    let mut found_garble = false;
    for _ in 0..50 {
      let ch = core_char_at(core_row, anchor_col, core_grow_ticks(), &mut rng);
      if ch != ' ' {
        found_garble = true;
        break;
      }
    }
    assert!(found_garble);
  }

  #[test]
  fn core_char_at_outside_radius() {
    let mut rng = rand::rng();
    let ch = core_char_at(0, 0, 0, &mut rng);
    assert_eq!(ch, ' ');
  }
}
