"""Tests for MeetingAudioWriter."""

from __future__ import annotations

import json
import threading
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path
from uuid import uuid4

import numpy as np
import pytest
from numpy.typing import NDArray

from noteflow.config.constants import DEFAULT_SAMPLE_RATE
from noteflow.infrastructure.audio.writer import MeetingAudioWriter
from noteflow.infrastructure.security.crypto import AesGcmCryptoBox, ChunkedAssetReader

# Test constants for audio frame sizes
AUDIO_FRAME_SIZE_SAMPLES = 1600
"""Standard audio frame size in samples (0.1 seconds at 16kHz)."""

PCM16_BYTES_PER_FRAME = 3200
"""PCM16 encoded size in bytes for AUDIO_FRAME_SIZE_SAMPLES (2 bytes per sample)."""

# crypto and meetings_dir fixtures are provided by tests/conftest.py


@dataclass
class WriterContext:
    """Context for audio writer tests with pre-configured writer and keys."""

    writer: MeetingAudioWriter
    meeting_id: str
    dek: bytes
    wrapped_dek: bytes
    meetings_dir: Path
    crypto: AesGcmCryptoBox


@pytest.fixture
def writer_context(crypto: AesGcmCryptoBox, meetings_dir: Path) -> WriterContext:
    """Create writer context with pre-generated DEK and meeting ID."""
    writer = MeetingAudioWriter(crypto, meetings_dir)
    dek = crypto.generate_dek()
    wrapped_dek = crypto.wrap_dek(dek)
    return WriterContext(
        writer=writer,
        meeting_id=str(uuid4()),
        dek=dek,
        wrapped_dek=wrapped_dek,
        meetings_dir=meetings_dir,
        crypto=crypto,
    )


@pytest.fixture
def open_writer(writer_context: WriterContext) -> Generator[WriterContext, None, None]:
    """Create and open a writer for recording."""
    writer_context.writer.open(
        writer_context.meeting_id,
        writer_context.dek,
        writer_context.wrapped_dek,
    )
    yield writer_context
    writer_context.writer.close()


def _write_sine_wave_chunks(writer: MeetingAudioWriter, num_chunks: int) -> NDArray[np.float32]:
    """Write sine wave chunks to writer and return concatenated original audio."""
    original_chunks: list[NDArray[np.float32]] = []
    for i in range(num_chunks):
        audio = np.sin(
            2 * np.pi * 440 * np.linspace(i, i + 0.1, AUDIO_FRAME_SIZE_SAMPLES)
        ).astype(np.float32)
        original_chunks.append(audio)
        writer.write_chunk(audio)
    return np.concatenate(original_chunks)


def _read_audio_from_encrypted_file(
    crypto: AesGcmCryptoBox, audio_path: Path, dek: bytes
) -> NDArray[np.float32]:
    """Read and decrypt audio from encrypted file, returning float32 samples."""
    reader = ChunkedAssetReader(crypto)
    reader.open(audio_path, dek)
    all_audio_bytes = b"".join(reader.read_chunks())
    reader.close()
    pcm16: NDArray[np.int16] = np.frombuffer(all_audio_bytes, dtype=np.int16)
    result: NDArray[np.float32] = pcm16.astype(np.float32) / 32767.0
    return result


CONCURRENT_WRITE_COUNT = 100
"""Number of audio chunks to write in concurrent tests."""

CONCURRENT_FLUSH_COUNT = 50
"""Number of flush operations in concurrent tests."""


def _write_random_chunks(writer: MeetingAudioWriter, num_chunks: int) -> None:
    """Write random audio chunks to writer (for concurrency tests)."""
    for _ in range(num_chunks):
        audio = np.random.uniform(-0.5, 0.5, AUDIO_FRAME_SIZE_SAMPLES).astype(np.float32)
        writer.write_chunk(audio)


def _flush_n_times(writer: MeetingAudioWriter, num_flushes: int) -> None:
    """Flush writer repeatedly (for concurrency tests)."""
    for _ in range(num_flushes):
        writer.flush()


class TestMeetingAudioWriterBasics:
    """Tests for MeetingAudioWriter basic operations."""

    def test_writer_creates_meeting_directory(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test writer creates meeting directory structure."""
        writer = MeetingAudioWriter(crypto, meetings_dir)
        meeting_id = str(uuid4())
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(meeting_id, dek, wrapped_dek)

        meeting_dir = meetings_dir / meeting_id
        assert meeting_dir.exists(), "Meeting directory should be created"
        assert (meeting_dir / "manifest.json").exists(), "manifest.json should be created"
        assert (meeting_dir / "audio.enc").exists(), "audio.enc should be created"

        writer.close()

    def test_manifest_contains_correct_metadata(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test manifest.json has required fields."""
        writer = MeetingAudioWriter(crypto, meetings_dir)
        meeting_id = str(uuid4())
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(meeting_id, dek, wrapped_dek, sample_rate=DEFAULT_SAMPLE_RATE)
        writer.close()

        manifest_path = meetings_dir / meeting_id / "manifest.json"
        manifest = json.loads(manifest_path.read_text())

        assert manifest["meeting_id"] == meeting_id, "Manifest should contain correct meeting_id"
        assert manifest["sample_rate"] == DEFAULT_SAMPLE_RATE, "Manifest should contain correct sample_rate"
        assert manifest["channels"] == 1, "Manifest should contain channels=1"
        assert manifest["format"] == "pcm16", "Manifest should contain format=pcm16"
        assert "wrapped_dek" in manifest, "Manifest should contain wrapped_dek"
        assert "created_at" in manifest, "Manifest should contain created_at"

    def test_write_chunk_converts_float32_to_pcm16(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test audio conversion from float32 to PCM16."""
        # Use small buffer to force immediate flush
        writer = MeetingAudioWriter(crypto, meetings_dir, buffer_size=1000)
        meeting_id = str(uuid4())
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(meeting_id, dek, wrapped_dek)

        # Create test audio: 1600 samples = 0.1 seconds at 16kHz
        test_audio = np.linspace(-1.0, 1.0, AUDIO_FRAME_SIZE_SAMPLES, dtype=np.float32)
        writer.write_chunk(test_audio)

        # Audio is 3200 bytes, buffer is 1000, so should flush
        assert writer.bytes_written > 0, "bytes_written should be positive after flush"
        # PCM16 = 2 bytes/sample = 3200 bytes raw, but encrypted with overhead
        assert writer.bytes_written > PCM16_BYTES_PER_FRAME, "bytes_written should exceed raw size due to encryption overhead"
        assert writer.chunk_count == 1, "chunk_count should be 1 after first flush"
        assert writer.write_count == 1, "write_count should be 1 after one write_chunk call"

        writer.close()

    def test_multiple_chunks_written(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test writing multiple audio chunks with buffering."""
        # Use small buffer to test buffering behavior
        writer = MeetingAudioWriter(crypto, meetings_dir, buffer_size=10000)
        meeting_id = str(uuid4())
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(meeting_id, dek, wrapped_dek)

        # Write 100 chunks of 1600 samples each (3200 bytes per write)
        # Buffer is 10000, so ~3 writes per encrypted chunk
        num_writes = 100
        bytes_per_write = PCM16_BYTES_PER_FRAME  # 3200 bytes

        for _ in range(num_writes):
            audio = np.random.uniform(-0.5, 0.5, AUDIO_FRAME_SIZE_SAMPLES).astype(np.float32)
            writer.write_chunk(audio)

        # write_count tracks incoming audio frames
        assert writer.write_count == num_writes, "write_count should match number of write_chunk calls"

        # Due to buffering, chunk_count should be much less than write_count
        # 100 writes * 3200 bytes = 320,000 bytes / 10000 buffer = ~32 flushes
        # Some bytes may still be buffered
        assert writer.chunk_count < num_writes, "chunk_count should be less than write_count due to buffering"

        # Flush remaining and check bytes written before close
        writer.flush()

        # Total raw bytes = 100 * 3200 = 320,000 bytes
        # Encrypted size includes overhead per chunk
        assert writer.bytes_written > num_writes * bytes_per_write, "bytes_written should exceed raw size due to encryption overhead"

        writer.close()

    def test_buffering_reduces_chunk_overhead(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test that buffering reduces encryption overhead."""
        # Create two writers with different buffer sizes
        small_buffer_writer = MeetingAudioWriter(crypto, meetings_dir, buffer_size=1000)
        large_buffer_writer = MeetingAudioWriter(crypto, meetings_dir, buffer_size=1_000_000)

        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        # Write same audio to both
        meeting_id_small = str(uuid4())
        meeting_id_large = str(uuid4())

        small_buffer_writer.open(meeting_id_small, dek, wrapped_dek)
        large_buffer_writer.open(meeting_id_large, dek, wrapped_dek)

        # Generate consistent test data
        np.random.seed(42)

        # Write 50 chunks (160,000 bytes raw)
        for _ in range(50):
            audio = np.random.uniform(-0.5, 0.5, 1600).astype(np.float32)
            small_buffer_writer.write_chunk(audio)

        np.random.seed(42)  # Reset seed to generate same audio

        for _ in range(50):
            audio = np.random.uniform(-0.5, 0.5, 1600).astype(np.float32)
            large_buffer_writer.write_chunk(audio)

        # Flush to ensure all data is written before comparing
        small_buffer_writer.flush()
        large_buffer_writer.flush()

        # Large buffer should have fewer encrypted chunks (less overhead)
        assert large_buffer_writer.chunk_count < small_buffer_writer.chunk_count, "Large buffer should produce fewer encrypted chunks"

        # Large buffer should use less total disk space due to fewer chunks
        # Each chunk has 32 bytes overhead (4 length + 12 nonce + 16 tag)
        assert large_buffer_writer.bytes_written < small_buffer_writer.bytes_written, "Large buffer should use less disk space due to fewer chunk overheads"

        small_buffer_writer.close()
        large_buffer_writer.close()

    def test_write_chunk_clamps_audio_minimum(self, writer_context: WriterContext) -> None:
        """Test audio values below -1 are clamped to -1."""
        ctx = writer_context
        ctx.writer.open(ctx.meeting_id, ctx.dek, ctx.wrapped_dek)
        ctx.writer.write_chunk(np.array([-2.0, 0.0, 2.0], dtype=np.float32))
        ctx.writer.close()

        read_audio: NDArray[np.float32] = _read_audio_from_encrypted_file(
            ctx.crypto, ctx.meetings_dir / ctx.meeting_id / "audio.enc", ctx.dek
        )
        assert read_audio.min() >= -1.0, "Clamped audio minimum should be >= -1.0"

    def test_write_chunk_clamps_audio_maximum(self, writer_context: WriterContext) -> None:
        """Test audio values above 1 are clamped to 1."""
        ctx = writer_context
        ctx.writer.open(ctx.meeting_id, ctx.dek, ctx.wrapped_dek)
        ctx.writer.write_chunk(np.array([-2.0, 0.0, 2.0], dtype=np.float32))
        ctx.writer.close()

        read_audio: NDArray[np.float32] = _read_audio_from_encrypted_file(
            ctx.crypto, ctx.meetings_dir / ctx.meeting_id / "audio.enc", ctx.dek
        )
        assert read_audio.max() <= 1.0, "Clamped audio maximum should be <= 1.0"

    def test_flush_writes_buffered_data(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test explicit flush writes buffered data to disk."""
        # Large buffer to prevent auto-flush
        writer = MeetingAudioWriter(crypto, meetings_dir, buffer_size=1_000_000)
        meeting_id = str(uuid4())
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(meeting_id, dek, wrapped_dek)

        # Write small audio chunk (won't trigger auto-flush)
        writer.write_chunk(np.zeros(AUDIO_FRAME_SIZE_SAMPLES, dtype=np.float32))

        # Data should be buffered, not written
        assert writer.buffered_bytes > 0, "Data should be buffered before flush"
        assert writer.chunk_count == 0, "chunk_count should be 0 before flush"

        # Explicit flush
        writer.flush()

        # Data should now be written
        assert writer.buffered_bytes == 0, "buffered_bytes should be 0 after flush"
        assert writer.chunk_count == 1, "chunk_count should be 1 after flush"
        assert writer.bytes_written > 0, "bytes_written should be positive after flush"

        writer.close()


class TestMeetingAudioWriterErrors:
    """Tests for MeetingAudioWriter error handling."""

    def test_writer_raises_if_already_open(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test writer raises RuntimeError if opened twice."""
        writer = MeetingAudioWriter(crypto, meetings_dir)
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(str(uuid4()), dek, wrapped_dek)

        with pytest.raises(RuntimeError, match="already open"):
            writer.open(str(uuid4()), dek, wrapped_dek)

        writer.close()

    def test_writer_raises_if_write_when_not_open(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test writer raises RuntimeError if write called before open."""
        writer = MeetingAudioWriter(crypto, meetings_dir)
        audio = np.zeros(AUDIO_FRAME_SIZE_SAMPLES, dtype=np.float32)

        with pytest.raises(RuntimeError, match="not open"):
            writer.write_chunk(audio)

    def test_close_is_idempotent(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test close can be called multiple times safely."""
        writer = MeetingAudioWriter(crypto, meetings_dir)
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(str(uuid4()), dek, wrapped_dek)
        writer.close()
        writer.close()  # Should not raise
        writer.close()  # Should not raise


class TestMeetingAudioWriterProperties:
    """Tests for MeetingAudioWriter properties."""

    def test_is_recording_property(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test is_recording property reflects writer state."""
        writer = MeetingAudioWriter(crypto, meetings_dir)
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        assert not writer.is_recording, "is_recording should be False before open"

        writer.open(str(uuid4()), dek, wrapped_dek)
        assert writer.is_recording, "is_recording should be True after open"

        writer.close()
        assert not writer.is_recording, "is_recording should be False after close"

    def test_meeting_dir_property(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test meeting_dir property returns correct path."""
        writer = MeetingAudioWriter(crypto, meetings_dir)
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)
        meeting_id = str(uuid4())

        assert writer.meeting_dir is None, "meeting_dir should be None before open"

        writer.open(meeting_id, dek, wrapped_dek)
        assert writer.meeting_dir == meetings_dir / meeting_id, "meeting_dir should match expected path after open"

        writer.close()
        assert writer.meeting_dir is None, "meeting_dir should be None after close"

    def test_bytes_written_when_closed(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test bytes_written returns 0 when not open."""
        writer = MeetingAudioWriter(crypto, meetings_dir)
        assert writer.bytes_written == 0, "bytes_written should be 0 when writer is not open"


ROUNDTRIP_NUM_CHUNKS = 10
"""Number of audio chunks to write for roundtrip tests."""


class TestMeetingAudioWriterIntegration:
    """Integration tests for audio roundtrip."""

    def test_audio_roundtrip_preserves_length(self, writer_context: WriterContext) -> None:
        """Test roundtrip preserves audio sample count."""
        ctx = writer_context
        ctx.writer.open(ctx.meeting_id, ctx.dek, ctx.wrapped_dek)
        original_audio = _write_sine_wave_chunks(ctx.writer, ROUNDTRIP_NUM_CHUNKS)
        ctx.writer.close()

        audio_path = ctx.meetings_dir / ctx.meeting_id / "audio.enc"
        read_audio: NDArray[np.float32] = _read_audio_from_encrypted_file(
            ctx.crypto, audio_path, ctx.dek
        )

        assert len(read_audio) == len(original_audio), "Read audio length should match original"

    def test_audio_roundtrip_preserves_content(self, writer_context: WriterContext) -> None:
        """Test roundtrip preserves audio content within quantization tolerance."""
        ctx = writer_context
        ctx.writer.open(ctx.meeting_id, ctx.dek, ctx.wrapped_dek)
        original_audio = _write_sine_wave_chunks(ctx.writer, ROUNDTRIP_NUM_CHUNKS)
        ctx.writer.close()

        audio_path = ctx.meetings_dir / ctx.meeting_id / "audio.enc"
        read_audio: NDArray[np.float32] = _read_audio_from_encrypted_file(
            ctx.crypto, audio_path, ctx.dek
        )

        # PCM16 quantization adds ~0.00003 max error
        assert np.allclose(
            original_audio, read_audio, atol=0.0001
        ), "Read audio should match original within quantization tolerance"

    def test_manifest_wrapped_dek_unwraps_successfully(
        self, writer_context: WriterContext
    ) -> None:
        """Test wrapped_dek from manifest can be unwrapped."""
        ctx = writer_context
        ctx.writer.open(ctx.meeting_id, ctx.dek, ctx.wrapped_dek)
        ctx.writer.write_chunk(np.zeros(AUDIO_FRAME_SIZE_SAMPLES, dtype=np.float32))
        ctx.writer.close()

        manifest_path = ctx.meetings_dir / ctx.meeting_id / "manifest.json"
        manifest = json.loads(manifest_path.read_text())
        wrapped_dek_hex = manifest["wrapped_dek"]

        unwrapped_dek = ctx.crypto.unwrap_dek(bytes.fromhex(wrapped_dek_hex))
        assert unwrapped_dek == ctx.dek, "Unwrapped DEK should match original"

    def test_manifest_wrapped_dek_decrypts_audio(self, writer_context: WriterContext) -> None:
        """Test unwrapped DEK from manifest can decrypt audio file."""
        ctx = writer_context
        ctx.writer.open(ctx.meeting_id, ctx.dek, ctx.wrapped_dek)
        ctx.writer.write_chunk(np.zeros(AUDIO_FRAME_SIZE_SAMPLES, dtype=np.float32))
        ctx.writer.close()

        manifest_path = ctx.meetings_dir / ctx.meeting_id / "manifest.json"
        manifest = json.loads(manifest_path.read_text())
        unwrapped_dek = ctx.crypto.unwrap_dek(bytes.fromhex(manifest["wrapped_dek"]))

        audio_path = ctx.meetings_dir / ctx.meeting_id / "audio.enc"
        reader = ChunkedAssetReader(ctx.crypto)
        reader.open(audio_path, unwrapped_dek)
        chunks = list(reader.read_chunks())
        reader.close()

        assert len(chunks) == 1, "Should read exactly one chunk that was written"


class ExposedAudioWriter(MeetingAudioWriter):
    """Subclass that exposes protected attributes for testing."""

    @property
    def flush_thread(self) -> threading.Thread | None:
        """Expose protected _flush_thread for testing."""
        return self._flush_thread

    @property
    def stop_flush_event(self) -> threading.Event:
        """Expose protected _stop_flush for testing."""
        return self._stop_flush


class TestMeetingAudioWriterPeriodicFlush:
    """Tests for periodic flush thread functionality."""

    def test_periodic_flush_thread_starts_on_open(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test periodic flush thread is started when writer is opened."""
        writer = ExposedAudioWriter(crypto, meetings_dir)
        meeting_id = str(uuid4())
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        assert writer.flush_thread is None, "Flush thread should be None before open"

        writer.open(meeting_id, dek, wrapped_dek)
        assert writer.flush_thread is not None, "Flush thread should be created after open"
        assert writer.flush_thread.is_alive(), "Flush thread should be alive after open"

        writer.close()
        flush_thread_after_close = writer.flush_thread
        assert (
            flush_thread_after_close is None or not flush_thread_after_close.is_alive()
        ), "Flush thread should be stopped after close"

    def test_periodic_flush_thread_stops_on_close(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test periodic flush thread stops cleanly on close."""
        writer = ExposedAudioWriter(crypto, meetings_dir)
        meeting_id = str(uuid4())
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(meeting_id, dek, wrapped_dek)
        flush_thread = writer.flush_thread
        assert flush_thread is not None, "Flush thread should be created after open"

        writer.close()

        # Thread should be stopped
        assert not flush_thread.is_alive(), "Flush thread should not be alive after close"
        assert writer.stop_flush_event.is_set(), "Stop flush event should be set after close"

    def test_concurrent_writes_complete_without_errors(
        self, writer_context: WriterContext
    ) -> None:
        """Test concurrent writes complete without raising exceptions."""

        ctx = writer_context
        large_buffer_writer = MeetingAudioWriter(ctx.crypto, ctx.meetings_dir, buffer_size=1_000_000)
        large_buffer_writer.open(ctx.meeting_id, ctx.dek, ctx.wrapped_dek)

        errors: list[Exception] = []

        def write_audio() -> None:
            try:
                _write_random_chunks(large_buffer_writer, CONCURRENT_WRITE_COUNT)
            except (RuntimeError, ValueError, OSError) as e:
                errors.append(e)

        def flush_repeatedly() -> None:
            try:
                _flush_n_times(large_buffer_writer, CONCURRENT_FLUSH_COUNT)
            except (RuntimeError, ValueError, OSError) as e:
                errors.append(e)

        write_thread = threading.Thread(target=write_audio)
        flush_thread = threading.Thread(target=flush_repeatedly)
        write_thread.start()
        flush_thread.start()
        write_thread.join()
        flush_thread.join()

        large_buffer_writer.close()
        assert not errors, "No exceptions should occur during concurrent writes and flushes"

    def test_concurrent_writes_preserve_count(self, writer_context: WriterContext) -> None:
        """Test concurrent writes preserve accurate write count."""
        ctx = writer_context
        large_buffer_writer = MeetingAudioWriter(ctx.crypto, ctx.meetings_dir, buffer_size=1_000_000)
        large_buffer_writer.open(ctx.meeting_id, ctx.dek, ctx.wrapped_dek)

        def write_audio() -> None:
            _write_random_chunks(large_buffer_writer, CONCURRENT_WRITE_COUNT)

        def flush_repeatedly() -> None:
            _flush_n_times(large_buffer_writer, CONCURRENT_FLUSH_COUNT)

        write_thread = threading.Thread(target=write_audio)
        flush_thread = threading.Thread(target=flush_repeatedly)
        write_thread.start()
        flush_thread.start()
        write_thread.join()
        flush_thread.join()

        assert (
            large_buffer_writer.write_count == CONCURRENT_WRITE_COUNT
        ), "write_count should match expected after concurrent writes"
        large_buffer_writer.close()

    def test_flush_when_closed_raises_error(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
    ) -> None:
        """Test flush raises RuntimeError when writer is closed."""
        writer = MeetingAudioWriter(crypto, meetings_dir)

        # Should raise when not open
        with pytest.raises(RuntimeError, match="not open"):
            writer.flush()

        # Open, then close, then flush should also raise
        meeting_id = str(uuid4())
        dek = crypto.generate_dek()
        wrapped_dek = crypto.wrap_dek(dek)

        writer.open(meeting_id, dek, wrapped_dek)
        writer.close()

        with pytest.raises(RuntimeError, match="not open"):
            writer.flush()
