"""
Parser for legacy Accordance .acc2 flat-file parallel/harmony modules.

These are binary files (not bundles) produced by older versions of
Accordance (pre-v7).  Each file contains a series of numbered
paragraphs, each mapping a parallel Bible passage across multiple books.

Binary layout
-------------
Offset 0x0000  Pascal string (1-byte length + ASCII text)  ── full title
               padded with spaces to 256 bytes total
Offset 0x0100  Pascal string (1-byte length + ASCII text)  ── abbrev
Offset 0x0110+ Free-form copyright/version metadata, terminated
               by \\r\\r  (double carriage return)
Offset varies  66-book index (32 bytes each: 1-byte Pascal book name)
               + Apocrypha extension
               + chapter-count abbreviation table
               + paragraph offset table (4-byte BE values)
               Content area ── concatenated paragraph entries
               Each entry:  <number><space><Title>  (no delimiters)
"""

import re
from pathlib import Path

from .module import AccordanceModule


class Acc2Module(AccordanceModule):
    """Parser for legacy .acc2 parallel/harmony tools (type 15).

    Provides ``entries()`` (a list of (number, title, text) tuples) and
    ``get_paragraph(n)`` for individual lookups.
    """

    # ------------------------------------------------------------------
    # Mac OS Roman → Unicode mappings (shared with CommentaryModule)
    # ------------------------------------------------------------------
    _MAC_ROMAN_MAP = {
        # 0x80-0x9F: Accented letters
        0x80: "\u00c4",  # Ä
        0x81: "\u00c5",  # Å
        0x82: "\u00c7",  # Ç
        0x83: "\u00c9",  # É
        0x84: "\u00d1",  # Ñ
        0x85: "\u00d6",  # Ö
        0x86: "\u00dc",  # Ü
        0x87: "\u00e1",  # á
        0x88: "\u00e0",  # à
        0x89: "\u00e2",  # â
        0x8A: "\u00e4",  # ä
        0x8B: "\u00e3",  # ã
        0x8C: "\u00e5",  # å
        0x8D: "\u00e7",  # ç
        0x8E: "\u00e9",  # é
        0x8F: "\u00e8",  # è
        0x90: "\u00ea",  # ê
        0x91: "\u00eb",  # ë
        0x92: "\u00ed",  # í
        0x93: "\u00ec",  # ì
        0x94: "\u00ee",  # î
        0x95: "\u00ef",  # ï
        0x96: "\u00f1",  # ñ
        0x97: "\u00f3",  # ó
        0x98: "\u00f2",  # ò
        0x99: "\u00f4",  # ô
        0x9A: "\u00f6",  # ö
        0x9B: "\u00f5",  # õ
        0x9C: "\u00fa",  # ú
        0x9D: "\u00f9",  # ù
        0x9E: "\u00fb",  # û
        0x9F: "\u00fc",  # ü
        # 0xC0-0xFF: Typographic characters
        0xC6: "\u00c6",  # Æ
        0xC9: "\u2026",  # ellipsis
        0xD0: "\u2013",  # en dash
        0xD1: "\u2014",  # em dash
        0xD2: "\u201c",  # left double quote
        0xD3: "\u201d",  # right double quote
        0xD4: "\u2018",  # left single quote
        0xD5: "\u2019",  # right single quote (apostrophe)
        0xD6: "\u00d6",  # Ö
        0xD7: "\u2021",  # double dagger
        0xDB: "\u2026",  # ellipsis
        0xE0: "\u2020",  # dagger
        0xE5: "\u2022",  # bullet
        0xE6: "\u00b6",  # pilcrow
        0xFA: "\u00fa",  # ú
    }

    # ------------------------------------------------------------------
    # Initialisation
    # ------------------------------------------------------------------

    def __init__(self, filepath: str | Path):
        super().__init__(filepath)
        self._content_start: int | None = None
        self._paragraphs: list[tuple[int, str, str]] = []

        self._find_content()
        if self._content_start is not None:
            self._parse_paragraphs()

    # ------------------------------------------------------------------
    # Content area detection
    # ------------------------------------------------------------------

    def _find_content(self) -> None:
        """Locate the start of the concatenated paragraph text.

        The binary index region contains book-name tables, abbreviation
        indices, and paragraph-offset tables.  We bypass all of that by
        scanning for the first paragraph heading (digit + space +
        uppercase letter) that appears in mixed-case English prose (the
        index tables are all uppercase).
        """
        size = self.size
        for pos in range(0x1000, size - 100):
            b = self.mm[pos]
            # Digit?
            if not (0x30 <= b <= 0x39):
                continue
            #  (?<!\\d)  — previous byte must not be a digit
            if pos > 0 and 0x30 <= self.mm[pos - 1] <= 0x39:
                continue
            # Must be followed by space + uppercase letter
            if self.mm[pos + 1] != 0x20:
                continue
            if not (0x41 <= self.mm[pos + 2] <= 0x5A):
                continue
            # Mixed case?  Real prose has lowercase letters nearby.
            chunk = self.mm[pos : pos + 100]
            lower_count = sum(1 for b in chunk if 0x61 <= b <= 0x7A)
            if lower_count >= 5:
                self._content_start = pos
                return

    # ------------------------------------------------------------------
    # Paragraph parsing
    # ------------------------------------------------------------------

    def _parse_paragraphs(self) -> None:
        """Split the content area into (number, title, text) entries.

        Paragraph boundaries are identified by the regex
        ``(?<!\\d)\\d+\\s[A-Z]`` (a number, then a space, then an
        uppercase letter, not preceded by another digit).
        """
        content = self.mm[self._content_start : self.size]
        text = content.decode("latin-1", errors="replace")

        matches = list(re.finditer(r"(?<!\d)\d+\s[A-Z]", text))
        if not matches:
            return

        paragraphs: list[tuple[int, str, str]] = []
        for i, match in enumerate(matches):
            start = match.start()

            # Paragraph end = start of next paragraph, or end of content
            end = matches[i + 1].start() if i + 1 < len(matches) else len(text)

            raw = text[start:end]
            full_text = self._clean_text(raw)

            # Extract the leading number (e.g. "1", "42")
            num_match = re.match(r"(\d+)\s", full_text)
            if not num_match:
                continue
            pnum = int(num_match.group(1))

            paragraphs.append((pnum, full_text, full_text))

        self._paragraphs = paragraphs

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def entries(self) -> list[tuple[int, str, str]]:
        """Return all paragraphs as (number, title, text) tuples.

        For legacy .acc2 harmony modules, each paragraph is a short
        heading such as "1 John the Baptist" without separate body text,
        so *title* and *text* are identical.
        """
        return list(self._paragraphs)

    def get_paragraph(self, n: int) -> tuple[int, str, str] | None:
        """Look up a paragraph by its number.

        Returns ``(number, title, text)`` or ``None`` if not found.
        """
        for pnum, title, text in self._paragraphs:
            if pnum == n:
                return (pnum, title, text)
        return None

    # ------------------------------------------------------------------
    # Encoding helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _clean_text(text: str) -> str:
        """Remap Mac OS Roman bytes (decoded as latin-1) to Unicode.

        Also truncates at the first null byte to prevent binary data
        leakage from module tails.
        """
        null_pos = text.find("\x00")
        if null_pos >= 0:
            text = text[:null_pos]
        for byte_val, replacement in Acc2Module._MAC_ROMAN_MAP.items():
            text = text.replace(chr(byte_val), replacement)
        return text
