"""
Binary append-only log for attestation records.

Format:
    [8 bytes: magic "VERISOO\x00"]
    [4 bytes: version (uint32 LE)]
    [record]*

Each record:
    [4 bytes: length (uint32 LE, not including this header)]
    [4 bytes: CRC32 of record bytes]
    [length bytes: record data]

This format is:
- Append-only (no rewriting)
- Self-describing (magic + version)
- Corruption-detectable (CRC32 per record)
- Streamable (length-prefixed, can read from offset)
- Federation-friendly (just ship bytes from offset N)
"""

from __future__ import annotations

import struct
import zlib
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO, Iterator

from .exceptions import VerisooError


MAGIC = b"VERISOO\x00"
VERSION = 1
HEADER_SIZE = len(MAGIC) + 4  # magic + version
RECORD_HEADER_SIZE = 8  # length + crc32


class LogCorruptionError(VerisooError):
    """Log file is corrupted."""

    def __init__(self, message: str, offset: int) -> None:
        super().__init__(message)
        self.offset = offset


@dataclass
class LogEntry:
    """An entry in the binary log."""

    offset: int  # Byte offset in file (start of record header)
    index: int  # Logical index (0, 1, 2, ...)
    data: bytes  # Record payload
    crc32: int  # Stored CRC32


class BinaryLog:
    """
    Append-only binary log file.

    Thread-safe for concurrent reads, single-writer for appends.
    """

    def __init__(self, path: Path) -> None:
        self.path = path
        self._size = 0  # Number of records
        self._file_size = 0  # Bytes written

        if path.exists():
            self._validate_and_count()
        else:
            self._initialize()

    def _initialize(self) -> None:
        """Create new log file with header."""
        with open(self.path, "wb") as f:
            f.write(MAGIC)
            f.write(struct.pack("<I", VERSION))
        self._file_size = HEADER_SIZE
        self._size = 0

    def _validate_and_count(self) -> None:
        """Validate existing log and count records."""
        with open(self.path, "rb") as f:
            # Check magic
            magic = f.read(len(MAGIC))
            if magic != MAGIC:
                raise LogCorruptionError(f"Invalid magic: {magic!r}", 0)

            # Check version
            version_bytes = f.read(4)
            if len(version_bytes) < 4:
                raise LogCorruptionError("Truncated header", len(MAGIC))
            version = struct.unpack("<I", version_bytes)[0]
            if version > VERSION:
                raise LogCorruptionError(f"Unknown version: {version}", len(MAGIC))

            # Count records
            count = 0
            while True:
                offset = f.tell()
                header = f.read(RECORD_HEADER_SIZE)
                if not header:
                    break
                if len(header) < RECORD_HEADER_SIZE:
                    raise LogCorruptionError("Truncated record header", offset)

                length, stored_crc = struct.unpack("<II", header)
                data = f.read(length)
                if len(data) < length:
                    raise LogCorruptionError("Truncated record data", offset)

                # Verify CRC
                actual_crc = zlib.crc32(data) & 0xFFFFFFFF
                if actual_crc != stored_crc:
                    raise LogCorruptionError(
                        f"CRC mismatch at record {count}: stored={stored_crc}, actual={actual_crc}",
                        offset,
                    )

                count += 1

            self._size = count
            self._file_size = f.tell()

    @property
    def size(self) -> int:
        """Number of records in the log."""
        return self._size

    @property
    def file_size(self) -> int:
        """Total bytes in the log file."""
        return self._file_size

    def append(self, data: bytes) -> int:
        """
        Append a record to the log.

        Returns the index of the new record.
        """
        crc = zlib.crc32(data) & 0xFFFFFFFF
        header = struct.pack("<II", len(data), crc)

        with open(self.path, "ab") as f:
            f.write(header)
            f.write(data)

        index = self._size
        self._size += 1
        self._file_size += RECORD_HEADER_SIZE + len(data)

        return index

    def read(self, index: int) -> bytes:
        """Read record at index."""
        if index < 0 or index >= self._size:
            raise IndexError(f"Index {index} out of range [0, {self._size})")

        for entry in self._iterate_from(0):
            if entry.index == index:
                return entry.data

        raise IndexError(f"Record {index} not found")  # Should never happen

    def read_range(self, start: int, end: int) -> list[bytes]:
        """Read records in range [start, end)."""
        if start < 0:
            start = 0
        if end > self._size:
            end = self._size

        results = []
        for entry in self._iterate_from(0):
            if entry.index >= end:
                break
            if entry.index >= start:
                results.append(entry.data)

        return results

    def iterate(self) -> Iterator[LogEntry]:
        """Iterate over all records."""
        yield from self._iterate_from(0)

    def _iterate_from(self, start_index: int) -> Iterator[LogEntry]:
        """Iterate records starting from index."""
        with open(self.path, "rb") as f:
            f.seek(HEADER_SIZE)

            index = 0
            while True:
                offset = f.tell()
                header = f.read(RECORD_HEADER_SIZE)
                if not header:
                    break
                if len(header) < RECORD_HEADER_SIZE:
                    break

                length, stored_crc = struct.unpack("<II", header)
                data = f.read(length)

                if index >= start_index:
                    yield LogEntry(
                        offset=offset,
                        index=index,
                        data=data,
                        crc32=stored_crc,
                    )

                index += 1

    def get_bytes_from_offset(self, offset: int) -> bytes:
        """
        Get raw bytes from offset to end of file.

        Used for federation: "send me everything after byte N"
        """
        if offset < HEADER_SIZE:
            offset = HEADER_SIZE

        with open(self.path, "rb") as f:
            f.seek(offset)
            return f.read()

    def append_raw_bytes(self, data: bytes) -> int:
        """
        Append raw bytes (pre-formatted records) from federation sync.

        Returns number of records appended.
        """
        # Validate the incoming data before appending
        records_added = 0
        pos = 0

        while pos < len(data):
            if pos + RECORD_HEADER_SIZE > len(data):
                raise LogCorruptionError("Truncated record in sync data", pos)

            length, stored_crc = struct.unpack("<II", data[pos : pos + RECORD_HEADER_SIZE])
            pos += RECORD_HEADER_SIZE

            if pos + length > len(data):
                raise LogCorruptionError("Truncated record data in sync data", pos)

            record_data = data[pos : pos + length]
            actual_crc = zlib.crc32(record_data) & 0xFFFFFFFF

            if actual_crc != stored_crc:
                raise LogCorruptionError(f"CRC mismatch in sync data at offset {pos}", pos)

            pos += length
            records_added += 1

        # All validated, append in one write
        with open(self.path, "ab") as f:
            f.write(data)

        self._size += records_added
        self._file_size += len(data)

        return records_added

    def truncate_to(self, size: int) -> None:
        """
        Truncate log to first `size` records.

        DANGEROUS: Only use for recovery from corruption.
        """
        if size >= self._size:
            return

        # Find byte offset after record `size-1`
        target_offset = HEADER_SIZE
        for entry in self._iterate_from(0):
            if entry.index >= size:
                target_offset = entry.offset
                break
            target_offset = entry.offset + RECORD_HEADER_SIZE + len(entry.data)

        with open(self.path, "r+b") as f:
            f.truncate(target_offset)

        self._size = size
        self._file_size = target_offset
