"""Independent wheel inventory and baseline-delta verification."""

from __future__ import annotations

import base64
import csv
import io
import zipfile
from dataclasses import dataclass
from pathlib import Path

from scripts.verify_nutricoach_v140_candidate_core import CandidateContractError, sha256_bytes


@dataclass(frozen=True, slots=True)
class WheelDelta:
    member_count: int
    changed_members: tuple[str, ...]


def _record_name(names: tuple[str, ...]) -> str:
    records = tuple(name for name in names if name.endswith(".dist-info/RECORD"))
    if len(records) != 1:
        raise CandidateContractError("wheel RECORD member is ambiguous")
    return records[0]


def _record_digest(encoded: str) -> str:
    algorithm, separator, value = encoded.partition("=")
    if algorithm != "sha256" or separator != "=":
        raise CandidateContractError("wheel RECORD digest algorithm is invalid")
    try:
        decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
    except ValueError as error:
        raise CandidateContractError("wheel RECORD digest encoding is invalid") from error
    if len(decoded) != 32:
        raise CandidateContractError("wheel RECORD digest length is invalid")
    return decoded.hex()


def verify_record(path: Path) -> tuple[str, ...]:
    """Verify that RECORD binds every wheel member exactly once."""
    with zipfile.ZipFile(path) as archive:
        names = tuple(archive.namelist())
        if len(names) != len(set(names)):
            raise CandidateContractError("wheel member inventory contains duplicates")
        record = _record_name(names)
        try:
            rows = tuple(csv.reader(io.StringIO(archive.read(record).decode("utf-8"))))
        except (KeyError, UnicodeDecodeError) as error:
            raise CandidateContractError("wheel RECORD cannot be decoded") from error
        seen: set[str] = set()
        for row in rows:
            if len(row) != 3:
                raise CandidateContractError("wheel RECORD row shape is invalid")
            relative, encoded, size = row
            if relative in seen or relative not in names:
                raise CandidateContractError("wheel RECORD path is invalid")
            seen.add(relative)
            if relative == record:
                if encoded or size:
                    raise CandidateContractError("wheel RECORD self-row is invalid")
                continue
            payload = archive.read(relative)
            if not size.isdecimal() or int(size) != len(payload):
                raise CandidateContractError(f"wheel RECORD size mismatch: {relative}")
            if _record_digest(encoded) != sha256_bytes(payload):
                raise CandidateContractError(f"wheel RECORD hash mismatch: {relative}")
        if seen != set(names):
            raise CandidateContractError("wheel RECORD inventory is incomplete")
        return names


def verify_delta(
    baseline_path: Path,
    candidate_path: Path,
    allowlist: tuple[str, ...],
) -> WheelDelta:
    """Require all non-allowlisted candidate members to equal baseline bytes."""
    baseline_names = verify_record(baseline_path)
    candidate_names = verify_record(candidate_path)
    with zipfile.ZipFile(baseline_path) as baseline, zipfile.ZipFile(candidate_path) as candidate:
        all_names = sorted(set(baseline_names) | set(candidate_names))
        changed = tuple(
            name
            for name in all_names
            if name not in baseline_names
            or name not in candidate_names
            or baseline.read(name) != candidate.read(name)
        )
        if tuple(sorted(allowlist)) != allowlist or len(allowlist) != len(set(allowlist)):
            raise CandidateContractError("wheel changed-member allowlist is not canonical")
        if changed != allowlist:
            missing = sorted(set(changed) - set(allowlist))
            extra = sorted(set(allowlist) - set(changed))
            raise CandidateContractError(
                f"wheel changed-member allowlist mismatch missing={missing} extra={extra}"
            )
        unchanged = set(baseline_names) & set(candidate_names) - set(changed)
        for member in unchanged:
            if baseline.read(member) != candidate.read(member):
                raise CandidateContractError(f"unchanged wheel member differs: {member}")
        return WheelDelta(len(candidate_names), changed)


def member_sha256(path: Path, member: str) -> str:
    with zipfile.ZipFile(path) as archive:
        try:
            return sha256_bytes(archive.read(member))
        except KeyError as error:
            raise CandidateContractError(f"wheel member missing: {member}") from error


def changed_members(baseline_path: Path, candidate_path: Path) -> tuple[str, ...]:
    """Return the canonical full changed-member inventory for manifest creation."""
    baseline_names = verify_record(baseline_path)
    candidate_names = verify_record(candidate_path)
    with zipfile.ZipFile(baseline_path) as baseline, zipfile.ZipFile(candidate_path) as candidate:
        return tuple(
            name
            for name in sorted(set(baseline_names) | set(candidate_names))
            if name not in baseline_names
            or name not in candidate_names
            or baseline.read(name) != candidate.read(name)
        )
