use anyhow::Context;
use chrono::{DateTime, Utc};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use url::Url;
use uuid::Uuid;

pub const BROWSER_RECORDING_SCHEMA: &str = "cephas.browser-recording.v1";
pub const DEFAULT_MAX_RECORDING_EVENTS: usize = 10_000;
pub const DEFAULT_MAX_RECORDING_FRAMES: usize = 1_200;
pub const DEFAULT_MAX_RECORDING_DIRECTORIES: usize = 25;
pub const DEFAULT_MAX_RECORDING_TEXT_BYTES: usize = 4 * 1024;
pub const DEFAULT_MAX_RECORDING_DETAILS_BYTES: usize = 16 * 1024;
const DEFAULT_MAX_RECORDING_DETAILS_ITEMS: usize = 32;
const DEFAULT_MAX_RECORDING_DETAILS_DEPTH: usize = 6;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrowserRecordingEventKind {
    Started,
    Stopped,
    BrowserAction,
    Navigation,
    Snapshot,
    Error,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserRecordingFrame {
    pub frame_id: Uuid,
    pub index: usize,
    pub file_name: String,
    pub reason: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BrowserRecordingEvent {
    pub schema: &'static str,
    pub recording_id: Uuid,
    pub event_id: Uuid,
    pub sequence: u64,
    pub occurred_at: DateTime<Utc>,
    pub kind: BrowserRecordingEventKind,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tab_id: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub frame: Option<BrowserRecordingFrame>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub details: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserRecordingManifest {
    pub schema: &'static str,
    pub recording_id: Uuid,
    pub title: String,
    pub started_at: DateTime<Utc>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ended_at: Option<DateTime<Utc>>,
    pub event_count: usize,
    pub frame_count: usize,
    pub max_events: usize,
    pub max_frames: usize,
    pub events_file: String,
    pub frames_dir: String,
    pub replay_file: String,
}

#[derive(Debug, PartialEq, Eq)]
pub struct PendingFrame {
    pub frame: BrowserRecordingFrame,
    path: PathBuf,
}

impl PendingFrame {
    pub fn path(&self) -> &Path {
        &self.path
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FinishedRecording {
    pub recording_id: Uuid,
    pub directory: PathBuf,
    pub manifest_path: PathBuf,
    pub replay_path: PathBuf,
    pub event_count: usize,
    pub frame_count: usize,
}

struct RecordingEventDraft {
    kind: BrowserRecordingEventKind,
    message: String,
    tab_id: Option<usize>,
    url: Option<Url>,
    title: Option<String>,
    frame: Option<BrowserRecordingFrame>,
    details: Option<Value>,
}

#[derive(Debug, Clone)]
pub struct BrowserRecordingStore {
    root: PathBuf,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BrowserRecordingDiagnostics {
    pub directories: usize,
    pub max_directories: usize,
    pub total_bytes: u64,
}

impl BrowserRecordingStore {
    pub fn new(root: Option<PathBuf>) -> anyhow::Result<Self> {
        let root = match root {
            Some(root) => root,
            None => default_recording_root()?,
        };
        Ok(Self { root })
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub fn start(&self, title: impl Into<String>) -> anyhow::Result<BrowserRecorder> {
        let recorder = BrowserRecorder::start_in(
            &self.root,
            title,
            DEFAULT_MAX_RECORDING_EVENTS,
            DEFAULT_MAX_RECORDING_FRAMES,
        )?;
        prune_recording_directories(
            &self.root,
            DEFAULT_MAX_RECORDING_DIRECTORIES,
            Some(recorder.directory()),
        )?;
        Ok(recorder)
    }

    pub fn prune_old_recordings(&self) -> anyhow::Result<usize> {
        prune_recording_directories(&self.root, DEFAULT_MAX_RECORDING_DIRECTORIES, None)
    }

    pub fn diagnostics(&self) -> anyhow::Result<BrowserRecordingDiagnostics> {
        let directories = recording_directories(&self.root)?;
        let total_bytes =
            directories
                .iter()
                .try_fold(0_u64, |total, directory| -> anyhow::Result<u64> {
                    Ok(total.saturating_add(directory_size(directory)?))
                })?;
        Ok(BrowserRecordingDiagnostics {
            directories: directories.len(),
            max_directories: DEFAULT_MAX_RECORDING_DIRECTORIES,
            total_bytes,
        })
    }
}

#[derive(Debug, Clone)]
pub struct BrowserRecorder {
    manifest: BrowserRecordingManifest,
    directory: PathBuf,
    events_path: PathBuf,
    frames_dir: PathBuf,
    replay_path: PathBuf,
    manifest_path: PathBuf,
    next_sequence: u64,
    events: Vec<BrowserRecordingEvent>,
}

impl BrowserRecorder {
    pub fn start_in(
        root: &Path,
        title: impl Into<String>,
        max_events: usize,
        max_frames: usize,
    ) -> anyhow::Result<Self> {
        let recording_id = Uuid::now_v7();
        let started_at = Utc::now();
        let directory_name = format!("{}-{recording_id}", started_at.format("%Y%m%dT%H%M%SZ"));
        let directory = root.join(directory_name);
        let frames_dir = directory.join("frames");
        fs::create_dir_all(&frames_dir).with_context(|| {
            format!(
                "failed to create recording directory {}",
                frames_dir.display()
            )
        })?;

        let events_path = directory.join("events.jsonl");
        let replay_path = directory.join("index.html");
        let manifest_path = directory.join("recording.json");
        let manifest = BrowserRecordingManifest {
            schema: BROWSER_RECORDING_SCHEMA,
            recording_id,
            title: bounded_recording_text(title),
            started_at,
            ended_at: None,
            event_count: 0,
            frame_count: 0,
            max_events: max_events.max(1),
            max_frames: max_frames.max(1),
            events_file: "events.jsonl".to_string(),
            frames_dir: "frames".to_string(),
            replay_file: "index.html".to_string(),
        };

        let mut recorder = Self {
            manifest,
            directory,
            events_path,
            frames_dir,
            replay_path,
            manifest_path,
            next_sequence: 1,
            events: Vec::new(),
        };
        recorder.record_event(
            BrowserRecordingEventKind::Started,
            "recording started",
            None,
            None,
            None,
            None,
        )?;
        Ok(recorder)
    }

    pub fn recording_id(&self) -> Uuid {
        self.manifest.recording_id
    }

    pub fn directory(&self) -> &Path {
        &self.directory
    }

    pub fn frame_count(&self) -> usize {
        self.manifest.frame_count
    }

    pub fn max_frames(&self) -> usize {
        self.manifest.max_frames
    }

    pub fn frame_limit_reached(&self) -> bool {
        self.manifest.frame_count >= self.manifest.max_frames
    }

    pub fn event_count(&self) -> usize {
        self.manifest.event_count
    }

    pub fn max_events(&self) -> usize {
        self.manifest.max_events
    }

    pub fn reserve_frame(&mut self, reason: impl Into<String>) -> Option<PendingFrame> {
        if self.manifest.frame_count >= self.manifest.max_frames {
            return None;
        }
        let frame_id = Uuid::now_v7();
        let index = self.manifest.frame_count + 1;
        let file_name = format!("{index:06}-{frame_id}.png");
        Some(PendingFrame {
            path: self.frames_dir.join(&file_name),
            frame: BrowserRecordingFrame {
                frame_id,
                index,
                file_name,
                reason: bounded_recording_text(reason),
            },
        })
    }

    pub fn record_frame(
        &mut self,
        pending: PendingFrame,
        tab_id: Option<usize>,
        url: Option<Url>,
        title: Option<String>,
    ) -> anyhow::Result<BrowserRecordingEvent> {
        if self.frame_limit_reached() {
            anyhow::bail!("recording frame limit reached");
        }
        self.manifest.frame_count += 1;
        self.record_event_with_frame(RecordingEventDraft {
            kind: BrowserRecordingEventKind::Snapshot,
            message: format!("snapshot captured: {}", pending.frame.reason),
            tab_id,
            url,
            title,
            frame: Some(pending.frame),
            details: None,
        })
    }

    pub fn record_event(
        &mut self,
        kind: BrowserRecordingEventKind,
        message: impl Into<String>,
        tab_id: Option<usize>,
        url: Option<Url>,
        title: Option<String>,
        details: Option<Value>,
    ) -> anyhow::Result<BrowserRecordingEvent> {
        self.record_event_with_frame(RecordingEventDraft {
            kind,
            message: message.into(),
            tab_id,
            url,
            title,
            frame: None,
            details,
        })
    }

    pub fn record_error(
        &mut self,
        message: impl Into<String>,
        tab_id: Option<usize>,
        url: Option<Url>,
        title: Option<String>,
        details: Option<Value>,
    ) -> anyhow::Result<BrowserRecordingEvent> {
        self.record_event(
            BrowserRecordingEventKind::Error,
            message,
            tab_id,
            url,
            title,
            details,
        )
    }

    pub fn finish(mut self) -> anyhow::Result<FinishedRecording> {
        let ended_at = Utc::now();
        self.manifest.ended_at = Some(ended_at);
        self.record_event(
            BrowserRecordingEventKind::Stopped,
            "recording stopped",
            None,
            None,
            None,
            None,
        )?;
        self.write_manifest()?;
        self.write_replay()?;

        Ok(FinishedRecording {
            recording_id: self.manifest.recording_id,
            directory: self.directory,
            manifest_path: self.manifest_path,
            replay_path: self.replay_path,
            event_count: self.manifest.event_count,
            frame_count: self.manifest.frame_count,
        })
    }

    fn record_event_with_frame(
        &mut self,
        draft: RecordingEventDraft,
    ) -> anyhow::Result<BrowserRecordingEvent> {
        if self.manifest.event_count >= self.manifest.max_events {
            return Ok(self
                .events
                .last()
                .cloned()
                .unwrap_or_else(|| BrowserRecordingEvent {
                    schema: BROWSER_RECORDING_SCHEMA,
                    recording_id: self.manifest.recording_id,
                    event_id: Uuid::now_v7(),
                    sequence: self.next_sequence,
                    occurred_at: Utc::now(),
                    kind: BrowserRecordingEventKind::Error,
                    message: "recording event limit reached".to_string(),
                    tab_id: None,
                    url: None,
                    title: None,
                    frame: None,
                    details: None,
                }));
        }

        let event = BrowserRecordingEvent {
            schema: BROWSER_RECORDING_SCHEMA,
            recording_id: self.manifest.recording_id,
            event_id: Uuid::now_v7(),
            sequence: self.next_sequence,
            occurred_at: Utc::now(),
            kind: draft.kind,
            message: bounded_recording_text(draft.message),
            tab_id: draft.tab_id,
            url: draft.url,
            title: draft.title.map(bounded_recording_text),
            frame: draft.frame,
            details: draft.details.map(bounded_recording_details),
        };
        self.next_sequence += 1;
        self.manifest.event_count += 1;
        append_json_line(&self.events_path, &event)?;
        self.events.push(event.clone());
        Ok(event)
    }

    fn write_manifest(&self) -> anyhow::Result<()> {
        let data = serde_json::to_string_pretty(&self.manifest)?;
        fs::write(&self.manifest_path, data).with_context(|| {
            format!(
                "failed to write recording manifest {}",
                self.manifest_path.display()
            )
        })
    }

    fn write_replay(&self) -> anyhow::Result<()> {
        fs::write(&self.replay_path, replay_html(&self.manifest, &self.events)).with_context(|| {
            format!(
                "failed to write recording replay {}",
                self.replay_path.display()
            )
        })
    }
}

fn bounded_recording_text(input: impl Into<String>) -> String {
    truncate_utf8(input.into(), DEFAULT_MAX_RECORDING_TEXT_BYTES)
}

fn truncate_utf8(input: String, max_bytes: usize) -> String {
    if input.len() <= max_bytes {
        return input;
    }
    let mut end = max_bytes;
    while !input.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}...[truncated]", &input[..end])
}

fn bounded_recording_details(value: Value) -> Value {
    let bounded = bounded_recording_details_value(value, 0);
    let Ok(serialized) = serde_json::to_vec(&bounded) else {
        return json!({ "truncated": true, "reason": "details serialization failed" });
    };
    if serialized.len() <= DEFAULT_MAX_RECORDING_DETAILS_BYTES {
        return bounded;
    }
    json!({
        "truncated": true,
        "reason": "details byte cap exceeded",
        "bounded_bytes": serialized.len(),
        "max_bytes": DEFAULT_MAX_RECORDING_DETAILS_BYTES
    })
}

fn bounded_recording_details_value(value: Value, depth: usize) -> Value {
    if depth >= DEFAULT_MAX_RECORDING_DETAILS_DEPTH {
        return json!({ "truncated": true, "reason": "details depth cap exceeded" });
    }
    match value {
        Value::String(value) => Value::String(bounded_recording_text(value)),
        Value::Array(values) => {
            let original_len = values.len();
            let mut values = values
                .into_iter()
                .take(DEFAULT_MAX_RECORDING_DETAILS_ITEMS)
                .map(|value| bounded_recording_details_value(value, depth + 1))
                .collect::<Vec<_>>();
            if original_len > DEFAULT_MAX_RECORDING_DETAILS_ITEMS {
                values.push(json!({
                    "truncated": true,
                    "omitted_items": original_len - DEFAULT_MAX_RECORDING_DETAILS_ITEMS
                }));
            }
            Value::Array(values)
        }
        Value::Object(values) => {
            let original_len = values.len();
            let mut bounded = Map::new();
            for (key, value) in values.into_iter().take(DEFAULT_MAX_RECORDING_DETAILS_ITEMS) {
                bounded.insert(
                    truncate_utf8(key, 128),
                    bounded_recording_details_value(value, depth + 1),
                );
            }
            if original_len > DEFAULT_MAX_RECORDING_DETAILS_ITEMS {
                bounded.insert(
                    "truncated".to_string(),
                    json!({ "omitted_items": original_len - DEFAULT_MAX_RECORDING_DETAILS_ITEMS }),
                );
            }
            Value::Object(bounded)
        }
        value => value,
    }
}

fn append_json_line<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).with_context(|| {
            format!("failed to create recording directory {}", parent.display())
        })?;
    }
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .with_context(|| format!("failed to open recording event log {}", path.display()))?;
    serde_json::to_writer(&mut file, value).context("failed to serialize recording event")?;
    file.write_all(b"\n")
        .context("failed to write recording event newline")
}

fn default_recording_root() -> anyhow::Result<PathBuf> {
    let dirs = ProjectDirs::from("dev", "Cephas", "Cephas")
        .context("could not determine platform recording directory")?;
    Ok(dirs.data_local_dir().join("recordings"))
}

fn recording_directories(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
    if !root.exists() {
        return Ok(Vec::new());
    }
    let mut directories = Vec::new();
    for entry in fs::read_dir(root)
        .with_context(|| format!("failed to read recording root {}", root.display()))?
    {
        let entry = entry?;
        if entry.file_type()?.is_dir() {
            directories.push(entry.path());
        }
    }
    directories.sort();
    Ok(directories)
}

fn prune_recording_directories(
    root: &Path,
    max_directories: usize,
    preserve: Option<&Path>,
) -> anyhow::Result<usize> {
    let max_directories = max_directories.max(1);
    let mut directories = recording_directories(root)?;
    let mut removed = 0;
    while directories.len() > max_directories {
        let Some(index) = directories
            .iter()
            .position(|directory| preserve != Some(directory.as_path()))
        else {
            break;
        };
        let directory = directories.remove(index);
        fs::remove_dir_all(&directory)
            .with_context(|| format!("failed to remove old recording {}", directory.display()))?;
        removed += 1;
    }
    Ok(removed)
}

fn directory_size(path: &Path) -> anyhow::Result<u64> {
    let metadata = fs::symlink_metadata(path)
        .with_context(|| format!("failed to inspect recording path {}", path.display()))?;
    if metadata.file_type().is_symlink() {
        return Ok(0);
    }
    if metadata.is_file() {
        return Ok(metadata.len());
    }
    if !metadata.is_dir() {
        return Ok(0);
    }

    let mut total = 0_u64;
    for entry in fs::read_dir(path)
        .with_context(|| format!("failed to read recording directory {}", path.display()))?
    {
        let entry = entry?;
        total = total.saturating_add(directory_size(&entry.path())?);
    }
    Ok(total)
}

fn replay_html(manifest: &BrowserRecordingManifest, events: &[BrowserRecordingEvent]) -> String {
    let title = html_escape::encode_text(&manifest.title);
    let mut timeline = String::new();
    for event in events {
        let message = html_escape::encode_text(&event.message);
        let kind_raw = format!("{:?}", event.kind);
        let kind = html_escape::encode_text(&kind_raw);
        let url = event
            .url
            .as_ref()
            .map(|url| html_escape::encode_text(url.as_str()).to_string())
            .unwrap_or_default();
        let frame = event.frame.as_ref().map(|frame| {
            let src_raw = format!("frames/{}", frame.file_name);
            let src = html_escape::encode_double_quoted_attribute(&src_raw);
            format!(r#"<a href="{src}"><img src="{src}" loading="lazy" alt="Recorded browser frame"></a>"#)
        }).unwrap_or_default();
        timeline.push_str(&format!(
            r#"<article class="event"><div class="meta"><span>{seq:04}</span><span>{kind}</span><span>{time}</span></div><h2>{message}</h2><p>{url}</p>{frame}</article>"#,
            seq = event.sequence,
            time = event.occurred_at.to_rfc3339(),
        ));
    }

    format!(
        r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Cephas Recording - {title}</title><style>body{{margin:0;background:#0b1020;color:#e5eefc;font:15px/1.55 Inter,system-ui,sans-serif}}main{{width:min(1160px,calc(100vw - 28px));margin:0 auto;padding:34px 0 70px}}h1{{margin:0 0 8px;font-size:clamp(34px,6vw,64px);letter-spacing:-.06em}}.summary{{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:22px 0}}.tile,.event{{border:1px solid rgba(148,163,184,.22);border-radius:18px;background:rgba(15,23,42,.82);box-shadow:0 20px 60px rgba(0,0,0,.22)}}.tile{{padding:16px}}.tile strong{{display:block;font-size:26px}}.tile span,.meta,p{{color:#9fb3cc}}.event{{overflow:hidden;margin:14px 0;padding:16px}}.meta{{display:flex;gap:12px;flex-wrap:wrap;font:800 12px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}}h2{{margin:10px 0 6px;font-size:19px}}img{{display:block;width:100%;height:auto;margin-top:14px;border-radius:14px;border:1px solid rgba(148,163,184,.2)}}a{{color:inherit}}@media(max-width:760px){{.summary{{grid-template-columns:1fr 1fr}}main{{padding-top:24px}}}}</style></head><body><main><h1>{title}</h1><p>Visual browser recording generated by Cephas. Frames are WebKit visible-region snapshots; events are ordered JSONL records.</p><section class="summary"><div class="tile"><strong>{events}</strong><span>Events</span></div><div class="tile"><strong>{frames}</strong><span>Frames</span></div><div class="tile"><strong>{started}</strong><span>Started</span></div><div class="tile"><strong>{ended}</strong><span>Ended</span></div></section>{timeline}</main></body></html>"#,
        events = manifest.event_count,
        frames = manifest.frame_count,
        started = manifest.started_at.format("%H:%M:%S"),
        ended = manifest
            .ended_at
            .map(|ended| ended.format("%H:%M:%S").to_string())
            .unwrap_or_else(|| "active".to_string()),
    )
}

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

    #[test]
    fn recording_writes_events_manifest_and_replay() {
        let dir = tempfile::tempdir().unwrap();
        let store = BrowserRecordingStore::new(Some(dir.path().join("recordings"))).unwrap();
        let mut recorder = store.start("Test recording").unwrap();
        assert_eq!(recorder.event_count(), 1);
        assert_eq!(recorder.recording_id().get_version_num(), 7);

        let page = Url::parse("https://example.com/").unwrap();
        recorder
            .record_event(
                BrowserRecordingEventKind::Navigation,
                "loaded page",
                Some(1),
                Some(page.clone()),
                Some("Example".to_string()),
                None,
            )
            .unwrap();
        let frame = recorder.reserve_frame("test").unwrap();
        fs::write(frame.path(), b"not a real png").unwrap();
        recorder
            .record_frame(frame, Some(1), Some(page), Some("Example".to_string()))
            .unwrap();
        let finished = recorder.finish().unwrap();

        assert_eq!(finished.frame_count, 1);
        assert!(finished.manifest_path.exists());
        assert!(finished.replay_path.exists());
        let events = fs::read_to_string(finished.directory.join("events.jsonl")).unwrap();
        assert_eq!(events.lines().count(), 4);
        let replay = fs::read_to_string(finished.replay_path).unwrap();
        assert!(replay.contains("Test recording"));
        assert!(replay.contains("loaded page"));
    }

    #[test]
    fn recording_respects_frame_limit() {
        let dir = tempfile::tempdir().unwrap();
        let mut recorder = BrowserRecorder::start_in(dir.path(), "Limited", 8, 1).unwrap();
        let frame = recorder.reserve_frame("first").unwrap();
        fs::write(frame.path(), b"fake").unwrap();
        recorder.record_frame(frame, None, None, None).unwrap();
        assert!(recorder.reserve_frame("second").is_none());
        assert!(recorder.frame_limit_reached());
        assert_eq!(recorder.max_frames(), 1);
        assert_eq!(recorder.max_events(), 8);
    }

    #[test]
    fn recording_rechecks_frame_limit_on_record() {
        let dir = tempfile::tempdir().unwrap();
        let mut recorder = BrowserRecorder::start_in(dir.path(), "Limited", 8, 1).unwrap();
        let first = recorder.reserve_frame("first").unwrap();
        let stale = PendingFrame {
            frame: BrowserRecordingFrame {
                frame_id: Uuid::now_v7(),
                index: 2,
                file_name: "stale.png".to_string(),
                reason: "stale".to_string(),
            },
            path: dir.path().join("stale.png"),
        };

        recorder.record_frame(first, None, None, None).unwrap();

        assert!(recorder.record_frame(stale, None, None, None).is_err());
        assert_eq!(recorder.frame_count(), 1);
    }

    #[test]
    fn recording_payloads_are_bounded_before_retention() {
        let dir = tempfile::tempdir().unwrap();
        let large = "x".repeat(DEFAULT_MAX_RECORDING_TEXT_BYTES * 3);
        let mut recorder = BrowserRecorder::start_in(dir.path(), large.clone(), 8, 8).unwrap();

        assert!(recorder.manifest.title.contains("[truncated]"));

        let event = recorder
            .record_event(
                BrowserRecordingEventKind::BrowserAction,
                large.clone(),
                None,
                None,
                Some(large.clone()),
                Some(serde_json::json!({
                    "large": large,
                    "items": (0..(DEFAULT_MAX_RECORDING_DETAILS_ITEMS + 8)).collect::<Vec<_>>()
                })),
            )
            .unwrap();

        assert!(event.message.contains("[truncated]"));
        assert!(event.title.unwrap().contains("[truncated]"));
        let details = event.details.unwrap();
        assert!(serde_json::to_vec(&details).unwrap().len() <= DEFAULT_MAX_RECORDING_DETAILS_BYTES);
        assert!(details["large"].as_str().unwrap().contains("[truncated]"));
        assert!(
            details["items"].as_array().unwrap().len() <= DEFAULT_MAX_RECORDING_DETAILS_ITEMS + 1
        );
    }

    #[test]
    fn recording_store_prunes_old_directories() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("recordings");
        for name in ["001-old", "002-old", "003-new"] {
            fs::create_dir_all(root.join(name)).unwrap();
        }

        let removed = prune_recording_directories(&root, 2, None).unwrap();

        assert_eq!(removed, 1);
        assert!(!root.join("001-old").exists());
        assert_eq!(recording_directories(&root).unwrap().len(), 2);

        let store = BrowserRecordingStore::new(Some(root)).unwrap();
        let diagnostics = store.diagnostics().unwrap();
        assert_eq!(diagnostics.directories, 2);
        assert_eq!(
            diagnostics.max_directories,
            DEFAULT_MAX_RECORDING_DIRECTORIES
        );
    }
}
