import mmap
import struct
from pathlib import Path

from .constants import _TITLE_MAC_ROMAN_MAP


class AccordanceModule:
    def __init__(self, filepath: str | Path):
        self.filepath = Path(filepath)
        self._closed = False
        self._f = open(self.filepath, "rb")
        try:
            self.size = self.filepath.stat().st_size
            self.mm = mmap.mmap(self._f.fileno(), 0, access=mmap.ACCESS_READ)
            try:
                magic0 = self.mm[0]
                if magic0 == 0xFF:
                    self.magic = (magic0 << 8) | self.mm[1]
                    self.title = self._read_title(2, 256)  # skip 2-byte magic
                else:
                    self.magic = magic0
                    self.title = self._read_title(1, 256)  # skip 1-byte magic

                self.abbrev = self._read_pascal_string(0x100)
                self.abbrev_offset = 0x100
            except Exception:
                self.mm.close()
                raise
        except Exception:
            self._f.close()
            raise

    def _read_pascal_string(self, offset: int) -> str:
        length = self.mm[offset]
        raw = self.mm[offset + 1 : offset + 1 + length]
        return raw.decode("ascii", errors="replace")

    def _read_title(self, offset: int, maxlen: int) -> str:
        """Read a Mac-Roman-encoded null-terminated title string.

        Module titles are stored in Mac Roman encoding starting after the
        magic byte(s).  This method skips the magic prefix, decodes as
        latin-1, then remaps Mac Roman bytes to proper Unicode.

        Slicing ``self.mm[offset:offset+maxlen]`` safely returns a shorter
        result when *offset+maxlen* exceeds the mmap length.
        """
        raw = self.mm[offset : offset + maxlen]
        end = raw.find(b"\x00")
        if end >= 0:
            raw = raw[:end]
        text = raw.decode("latin-1", errors="replace")
        # Remap Mac Roman bytes in the 0x80-0xFF range
        chars = []
        for ch in text:
            b = ord(ch)
            if b in _TITLE_MAC_ROMAN_MAP:
                chars.append(_TITLE_MAC_ROMAN_MAP[b])
            else:
                chars.append(ch)
        return "".join(chars).strip()

    def _read_cstring(self, offset: int, maxlen: int) -> str:
        raw = self.mm[offset : offset + maxlen]
        end = raw.find(b"\x00")
        if end >= 0:
            raw = raw[:end]
        return raw.decode("latin-1", errors="replace").rstrip()

    def _read_u8(self, offset: int) -> int:
        """Read an unsigned 8-bit integer at *offset*.

        Caller must ensure ``offset < len(self.mm)``.
        """
        return self.mm[offset]

    def _read_u16_le(self, offset: int) -> int:
        """Read an unsigned 16-bit little-endian integer at *offset*.

        Caller must ensure ``offset + 1 < len(self.mm)``.
        """
        return struct.unpack_from("<H", self.mm, offset)[0]

    def _read_u32_le(self, offset: int) -> int:
        """Read an unsigned 32-bit little-endian integer at *offset*.

        Caller must ensure ``offset + 3 < len(self.mm)``.
        """
        return struct.unpack_from("<I", self.mm, offset)[0]

    def _read_i32_le(self, offset: int) -> int:
        """Read a signed 32-bit little-endian integer at *offset*.

        Caller must ensure ``offset + 3 < len(self.mm)``.
        """
        return struct.unpack_from("<i", self.mm, offset)[0]

    def _read_u24_be_after_tag(self, offset: int) -> int:
        """Read a 24-bit big-endian integer following a 1-byte tag.

        The on-disk format stores a 1-byte entry-type tag at ``offset``,
        followed by a 24-bit page offset at ``offset+1``..``offset+3``.
        This method skips the tag byte and returns only the u24 value.

        Callers are responsible for ensuring ``offset + 3 < len(self.mm)``.
        """
        return (
            (self.mm[offset + 1] << 16)
            | (self.mm[offset + 2] << 8)
            | self.mm[offset + 3]
        )

    def find_bytes(self, pattern: bytes, start: int = 0) -> int:
        return self.mm.find(pattern, start)

    def read_bytes(self, offset: int, length: int) -> bytes:
        return self.mm[offset : offset + length]

    def close(self):
        if not hasattr(self, "_closed") or not self._closed:
            self._closed = True
            try:
                self.mm.close()
            except Exception:
                pass  # mmap already closed or never opened
            try:
                self._f.close()
            except Exception:
                pass

    def __del__(self):
        try:
            self.close()
        except Exception:
            pass

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.close()

    def __repr__(self):
        abbrev = getattr(self, "abbrev", "?")
        return f"AccordanceModule({abbrev}, type={self.magic:#x}, {self.size} bytes)"
