# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
s57/catalog.py
--------------
Exchange-set scrutiny: reads the **CATALOG.0xx** file (the exchange set's table
of contents) and the files actually present, so the exchange-set checks can
verify integrity across the whole set rather than one cell at a time.

The catalogue is an ISO 8211 file whose records carry the **Catalogue Directory
(CATD)** field — one entry per file in the set, with subfields (all ASCII):

    RCNM(=CD) RCID FILE LFIL VOLM IMPL SLAT WLON NLAT ELON CRCS COMT

(per S-57 App B.1 §6.2.2). Data-set files use IMPL=BIN and carry geographic
bounds and a CRC; the catalogue and README carry no CRC.

``ExchangeSet.from_source(folder_or_zip)`` finds the catalogue, parses its CATD
entries, and indexes the files present (folder or zip member), exposing a bytes
accessor for CRC verification. GDAL-free; import-safe without QGIS.

CRC: S-57 uses CRC-32 (ANSI/IEEE 802.3), the same polynomial as ``zlib.crc32``;
the value is stored as ASCII hex, least-significant-byte-first (§5.9). The exact
ASCII byte-order convention is confirmed defensively (both orderings accepted)
and the CRC check is flagged provisional until validated on a real exchange set.
"""

import os
import re
import zlib
import zipfile

try:                                                    # plugin context
    from ..iso8211 import Iso8211Reader
except (ImportError, ValueError):                       # headless / top-level
    from iso8211 import Iso8211Reader

CATALOG_RE = re.compile(r"(?i)^CATALOG\.\d{3}$")
CELL_RE = re.compile(r"(?i)^[A-Z0-9]{8}\.\d{3}$")


def crc32_hex(data):
    """Standard CRC-32 of ``data`` as an 8-char lowercase hex string."""
    return f"{zlib.crc32(data) & 0xFFFFFFFF:08x}"


def crc_matches(data, crcs_str):
    """True if the file's CRC matches the catalogue CRCS value.

    Accepts the value as stored (hex) and byte-reversed, since S-57 records the
    CRC least-significant-byte-first and producers vary in how the ASCII is laid
    out. Provisional until validated on a real exchange set.
    """
    if not crcs_str:
        return None                                     # nothing to check
    want = "".join(ch for ch in str(crcs_str).strip() if ch in "0123456789abcdefABCDEF").lower()
    if len(want) != 8:
        return None
    got = crc32_hex(data)
    swapped = "".join(reversed([want[i:i + 2] for i in range(0, 8, 2)]))
    return got in (want, swapped)


class CatalogEntry:
    __slots__ = ("rcid", "file", "lfil", "volm", "impl",
                 "slat", "wlon", "nlat", "elon", "crcs", "comt")

    def __init__(self, d):
        self.rcid = d.get("RCID")
        self.file = (d.get("FILE") or "").strip()
        self.lfil = d.get("LFIL")
        self.volm = d.get("VOLM")
        self.impl = (d.get("IMPL") or "").strip().upper()
        self.slat = d.get("SLAT")
        self.wlon = d.get("WLON")
        self.nlat = d.get("NLAT")
        self.elon = d.get("ELON")
        self.crcs = d.get("CRCS")
        self.comt = d.get("COMT")

    @property
    def basename(self):
        return os.path.basename(self.file.replace("\\", "/")).upper()

    @property
    def is_cell(self):
        return bool(CELL_RE.match(self.basename))


class ExchangeSet:
    """Parsed CATALOG.0xx plus the files actually present in the set."""

    def __init__(self, source=""):
        self.source = source
        self.is_zip = False
        self.catalog_name = None
        self.catalog_error = None
        self.entries = []                 # [CatalogEntry]
        self.present = {}                 # BASENAME(upper) -> handle (path or zip member)
        self._zip = None                  # open ZipFile, for byte access
        self.catalog_count = 0            # >1 means several sets merged together

    @property
    def merged(self):
        """True when the source bundles more than one exchange set (several
        CATALOG.0xx files). Exchange-structure checks then validate against the
        union of catalogues rather than assuming a single set."""
        return self.catalog_count > 1

    # -- construction ---------------------------------------------------------
    @classmethod
    def from_source(cls, source):
        es = cls(source=source)
        try:
            if zipfile.is_zipfile(source):
                es._from_zip(source)
            elif os.path.isdir(source):
                es._from_dir(source)
            else:
                es.catalog_error = "source is neither a folder nor a zip"
        except Exception as exc:                        # noqa: BLE001
            es.catalog_error = f"{exc}"
        return es

    def _from_dir(self, root):
        catalog_paths = []
        for dirpath, _dirs, files in os.walk(root):
            for fn in files:
                up = fn.upper()
                full = os.path.join(dirpath, fn)
                self.present[up] = full
                if CATALOG_RE.match(fn):
                    catalog_paths.append(full)
                    self.catalog_name = fn.upper()
        self.catalog_count = len(catalog_paths)
        for cp in catalog_paths:
            with open(cp, "rb") as fh:
                self._parse_catalog(fh.read())
        if not catalog_paths:
            self.catalog_error = "no CATALOG.0xx found in the source"

    def _from_zip(self, path):
        self.is_zip = True
        self._zip = zipfile.ZipFile(path)
        catalog_members = []
        for m in self._zip.namelist():
            if m.endswith("/"):
                continue
            base = os.path.basename(m).upper()
            self.present[base] = m
            if CATALOG_RE.match(os.path.basename(m)):
                catalog_members.append(m)
                self.catalog_name = base
        self.catalog_count = len(catalog_members)
        for cm in catalog_members:
            self._parse_catalog(self._zip.read(cm))
        if not catalog_members:
            self.catalog_error = "no CATALOG.0xx found in the zip"

    def _parse_catalog(self, data):
        try:
            reader = Iso8211Reader(data)
            for rec in reader.records():
                for d in rec.fields.get("CATD", []):
                    self.entries.append(CatalogEntry(d))
        except Exception as exc:                        # noqa: BLE001
            self.catalog_error = f"catalogue unreadable: {exc}"

    # -- access ---------------------------------------------------------------
    def file_bytes(self, basename):
        """Bytes of a present file by basename, or None."""
        handle = self.present.get(basename.upper())
        if handle is None:
            return None
        try:
            if self.is_zip:
                return self._zip.read(handle)
            with open(handle, "rb") as fh:
                return fh.read()
        except Exception:                               # noqa: BLE001
            return None

    def present_cells(self):
        """Basenames of .000 base cells actually present."""
        return {b for b in self.present
                if CELL_RE.match(b) and b.endswith(".000")}

    def has_catalog(self):
        return bool(self.entries) and not self.catalog_error

    def close(self):
        if self._zip is not None:
            try:
                self._zip.close()
            except Exception:                           # noqa: BLE001
                pass
