"""Streaming encrypted audio file writer for meetings."""

from __future__ import annotations

import io
import json
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

import numpy as np

from noteflow.config.constants import (
    AUDIO_BUFFER_SIZE_BYTES,
    DEFAULT_SAMPLE_RATE,
    PERIODIC_FLUSH_INTERVAL_SECONDS,
)
from noteflow.infrastructure.logging import get_logger
from noteflow.infrastructure.security.crypto import ChunkedAssetWriter

if TYPE_CHECKING:
    from numpy.typing import NDArray

    from noteflow.infrastructure.security.crypto import AesGcmCryptoBox

logger = get_logger(__name__)


class MeetingAudioWriter:
    """Write audio chunks to encrypted meeting file.

    Manage meeting directory creation, manifest file, and encrypted audio storage.
    Uses ChunkedAssetWriter for the actual encryption.

    Audio data is buffered internally to reduce encryption overhead. Each encrypted
    chunk has 28 bytes overhead (12 byte nonce + 16 byte tag) plus 4 byte length
    prefix. Buffering aggregates small writes into larger chunks (~320KB) before
    encryption to minimize this overhead.

    A background thread periodically flushes the buffer every 2 seconds to minimize
    data loss on crashes. All buffer access is protected by a lock.

    Directory structure:
        ~/.noteflow/meetings/<meeting-uuid>/
        ├── manifest.json    # Meeting metadata + wrapped DEK
        └── audio.enc       # Encrypted PCM16 chunks (NFAE format)
    """

    def __init__(
        self,
        crypto: AesGcmCryptoBox,
        meetings_dir: Path,
        buffer_size: int = AUDIO_BUFFER_SIZE_BYTES,
    ) -> None:
        """Initialize audio writer.

        Args:
            crypto: CryptoBox instance for encryption operations.
            meetings_dir: Root directory for all meetings (e.g., ~/.noteflow/meetings).
            buffer_size: Buffer size threshold in bytes before flushing to disk.
                Defaults to AUDIO_BUFFER_SIZE_BYTES (~320KB = 10 seconds at 16kHz).
        """
        self._crypto = crypto
        self._meetings_dir = meetings_dir
        self._buffer_size = buffer_size
        self._asset_writer: ChunkedAssetWriter | None = None
        self._meeting_dir: Path | None = None
        self._sample_rate: int = DEFAULT_SAMPLE_RATE
        self._chunk_count: int = 0
        self._write_count: int = 0
        self._buffer: io.BytesIO = io.BytesIO()

        # Thread-safety for periodic flush
        self._buffer_lock = threading.Lock()
        self._flush_thread: threading.Thread | None = None
        self._stop_flush = threading.Event()

    def open(
        self,
        meeting_id: str,
        dek: bytes,
        wrapped_dek: bytes,
        sample_rate: int = DEFAULT_SAMPLE_RATE,
        asset_path: str | None = None,
    ) -> None:
        """Open meeting for audio writing.

        Create meeting directory, write manifest, open encrypted audio file.

        Args:
            meeting_id: Meeting UUID string.
            dek: Unwrapped data encryption key (32 bytes).
            wrapped_dek: Encrypted DEK to store in manifest.
            sample_rate: Audio sample rate (default 16000 Hz).
            asset_path: Relative path for audio storage (defaults to meeting_id).
                This allows meetings_dir to change without orphaning files.

        Raises:
            RuntimeError: If already open.
            OSError: If directory creation fails.
        """
        if self._asset_writer is not None:
            raise RuntimeError("Writer already open")

        # Use asset_path if provided, otherwise default to meeting_id
        storage_path = asset_path or meeting_id

        # Create meeting directory
        self._meeting_dir = self._meetings_dir / storage_path
        self._meeting_dir.mkdir(parents=True, exist_ok=True)

        # Write manifest.json
        manifest = {
            "meeting_id": meeting_id,
            "created_at": datetime.now(UTC).isoformat(),
            "sample_rate": sample_rate,
            "channels": 1,
            "format": "pcm16",
            "wrapped_dek": wrapped_dek.hex(),  # Store as hex string
        }
        manifest_path = self._meeting_dir / "manifest.json"
        manifest_path.write_text(json.dumps(manifest, indent=2))

        # Open encrypted audio file
        audio_path = self._meeting_dir / "audio.enc"
        self._asset_writer = ChunkedAssetWriter(self._crypto)
        self._asset_writer.open(audio_path, dek)

        self._sample_rate = sample_rate
        self._chunk_count = 0
        self._write_count = 0
        self._buffer = io.BytesIO()

        # Start periodic flush thread for crash resilience
        self._stop_flush.clear()
        self._flush_thread = threading.Thread(
            target=self._periodic_flush_loop,
            name=f"AudioFlush-{meeting_id[:8]}",
            daemon=True,
        )
        self._flush_thread.start()

        logger.info(
            "Opened audio writer: meeting=%s, dir=%s, buffer_size=%d",
            meeting_id,
            self._meeting_dir,
            self._buffer_size,
        )

    def _periodic_flush_loop(self) -> None:
        """Background thread: periodically flush buffer for crash resilience."""
        while not self._stop_flush.wait(timeout=PERIODIC_FLUSH_INTERVAL_SECONDS):
            try:
                self._flush_if_open()
            except OSError:
                logger.exception("Periodic flush failed")

    def _flush_if_open(self) -> None:
        """Flush buffer if writer is open (thread-safe, no exception if closed)."""
        with self._buffer_lock:
            if (
                self._asset_writer is not None
                and self._asset_writer.is_open
                and self._buffer.tell() > 0
            ):
                self._flush_buffer_unlocked()

    def write_chunk(self, audio: NDArray[np.float32]) -> None:
        """Write audio chunk to internal buffer (convert float32 → PCM16).

        Audio is buffered internally and flushed to encrypted storage when the
        buffer exceeds the configured threshold. Call flush() to force immediate
        write, or close() to finalize.

        Args:
            audio: Audio samples as float32 array (-1.0 to 1.0).

        Raises:
            RuntimeError: If not open.
        """
        if self._asset_writer is None or not self._asset_writer.is_open:
            raise RuntimeError("Writer not open")

        # Convert float32 [-1.0, 1.0] to int16 [-32768, 32767]
        # Clamp to prevent overflow on conversion
        audio_clamped = np.clip(audio, -1.0, 1.0)
        pcm16 = (audio_clamped * 32767.0).astype(np.int16)

        with self._buffer_lock:
            # Append to buffer
            self._buffer.write(pcm16.tobytes())
            self._write_count += 1

            # Flush buffer if threshold exceeded
            if self._buffer.tell() >= self._buffer_size:
                self._flush_buffer_unlocked()

    def flush(self) -> None:
        """Force flush buffered audio to encrypted storage.

        Call this to ensure all buffered audio is written immediately.
        Normally only needed before a long pause or when precise timing matters.

        Raises:
            RuntimeError: If not open.
        """
        if self._asset_writer is None or not self._asset_writer.is_open:
            raise RuntimeError("Writer not open")

        with self._buffer_lock:
            if self._buffer.tell() > 0:
                self._flush_buffer_unlocked()

    def _flush_buffer_unlocked(self) -> None:
        """Flush internal buffer to encrypted storage.

        Must be called with _buffer_lock held.
        """
        if self._asset_writer is None:
            return

        if buffer_bytes := self._buffer.getvalue():
            self._asset_writer.write_chunk(buffer_bytes)
            self._chunk_count += 1
            logger.debug(
                "Flushed audio buffer: %d bytes, chunk #%d",
                len(buffer_bytes),
                self._chunk_count,
            )

        # Reset buffer
        self._buffer = io.BytesIO()

    def close(self) -> None:
        """Close audio writer and finalize files.

        Stops the periodic flush thread, flushes remaining audio, and closes files.
        Safe to call if already closed or never opened.
        """
        # Stop periodic flush thread first (3s timeout for graceful shutdown)
        self._stop_flush.set()
        if self._flush_thread is not None:
            self._flush_thread.join(timeout=3.0)
            if self._flush_thread.is_alive():
                logger.warning("Audio flush thread did not stop within timeout")
            self._flush_thread = None

        if self._asset_writer is not None:
            # Flush remaining buffer under lock
            with self._buffer_lock:
                self._flush_buffer_unlocked()

            bytes_written = self._asset_writer.bytes_written
            self._asset_writer.close()
            self._asset_writer = None

            logger.info(
                "Closed audio writer: dir=%s, writes=%d, encrypted_chunks=%d, bytes=%d",
                self._meeting_dir,
                self._write_count,
                self._chunk_count,
                bytes_written,
            )

        self._meeting_dir = None
        self._chunk_count = 0
        self._write_count = 0
        with self._buffer_lock:
            self._buffer = io.BytesIO()

    @property
    def is_recording(self) -> bool:
        """Check if writer is currently open for recording."""
        return self._asset_writer is not None and self._asset_writer.is_open

    @property
    def bytes_written(self) -> int:
        """Total encrypted bytes written to audio.enc file."""
        return 0 if self._asset_writer is None else self._asset_writer.bytes_written

    @property
    def chunk_count(self) -> int:
        """Number of encrypted chunks written to disk.

        Due to buffering, this may be less than write_count.
        """
        return self._chunk_count

    @property
    def write_count(self) -> int:
        """Number of write_chunk() calls made.

        This counts incoming audio frames, not encrypted chunks written to disk.
        """
        return self._write_count

    @property
    def buffered_bytes(self) -> int:
        """Current bytes pending in buffer, not yet written to disk."""
        with self._buffer_lock:
            return self._buffer.tell()

    @property
    def meeting_dir(self) -> Path | None:
        """Current meeting directory, or None if not open."""
        return self._meeting_dir
