"""
FRISTÅENDE UPPACKARE — arkivgarantin, tvåsidig
===============================================
Packar upp ett CAP-Shield-paket UTAN gatewayen, UTAN nätverk, och utan
någon annan del av systemet än den här filen.

VARFÖR DENNA FINNS:

  Arkivgarantin lovar att en ordboksversion aldrig raderas, så att ett
  paket komprimerat idag går att packa upp om tio år. Men det löftet var
  ensidigt: det förutsatte att CAP-Shield fortfarande finns.

  Varje företagsköpare kommer att fråga vad som händer med deras
  komprimerade arkiv om leverantören lägger ner. Robin är ensam
  grundare. Frågan är oundviklig, och "vi finns kvar" är inte ett svar.

  Med den här filen plus en export är svaret istället: du packar upp
  själv, för alltid, oavsett vad som händer med oss.

VAD SOM BEHÖVS:
  * Den här filen (en fil, inga beroenden utom zstandard)
  * Ordboksbinärerna ur din export (cap-export-*.tar.gz)
  * Nyckelringen, OM paketen är krypterade — den lämnar aldrig servern
    automatiskt, du måste ha tagit backup på den

VAD DEN INTE GÖR:
  Den läser inte minneslagret och den väljer ingen kontext. Den packar
  upp paket. Det är allt, och det är det som måste överleva oss.

ANVÄNDNING:

    python cap_unpack.py --export cap-export-2026-08-23.tar.gz \\
                         --paket mina-paket/

    python cap_unpack.py --export cap-export.tar.gz --ett paket.cap
"""

from __future__ import annotations

import argparse
import json
import sys
import tarfile
from pathlib import Path

# Paketformat v2. Läses här på nytt i stället för att importeras, så att
# filen är verkligt fristående — en import från cap_packet hade gjort
# uppackaren beroende av resten av systemet, vilket är precis det den
# ska bevisa att den inte är.
# PAKETFORMAT v2. Läses här på nytt i stället för att importeras, så att
# filen är verkligt fristående — en import från cap_packet hade gjort
# uppackaren beroende av resten av systemet, vilket är precis det den ska
# bevisa att den inte är.
#
# Formatet, byte för byte:
#   [0]   0xC0 | version   (0xC2 för v2)
#   [1]   flaggor
#   ...   ordbok: varint kod ELLER varint längd + namn
#   ...   varint ordboksversion
#   ...   om KRYPTERAD: varint kuvertlängd + kuvert
#   ...   varint nyttolastlängd
#   ...   om inte NO_CRC: 4 byte CRC32
#   ...   nyttolast
V2_MARKER = 0xC0
V1_MAGIC = b"CAP1"
FLAGG_KOMPRIMERAD = 0x01
FLAGG_KRYPTERAD = 0x02
FLAGG_BUNT = 0x08
FLAGG_NUMERISK_ORDBOK = 0x40
FLAGG_INGEN_CRC = 0x80


def _las_varint(b: bytes, i: int) -> tuple[int, int]:
    """Varint från position i. Returnerar (värde, ny position)."""
    v = skift = 0
    while i < len(b):
        byte = b[i]
        i += 1
        v |= (byte & 0x7F) << skift
        if not byte & 0x80:
            return v, i
        skift += 7
        if skift > 35:
            raise UnpackError("Trasig varint i headern.")
    raise UnpackError("Paketet tar slut mitt i en varint.")


class UnpackError(RuntimeError):
    pass


def las_export(arkiv: Path) -> dict:
    """Plocka ut ordböckerna ur ett exportarkiv.

    Returnerar {dict_id: {version: bytes}}.
    """
    ordbocker: dict[str, dict[int, bytes]] = {}
    manifest = None

    with tarfile.open(arkiv, "r:gz") as tar:
        for m in tar.getmembers():
            if m.name.endswith("MANIFEST.json"):
                manifest = json.loads(tar.extractfile(m).read())
                continue
            if not m.name.endswith(".zdict"):
                continue
            # .../cap_dicts/<dict_id>/v3.zdict
            delar = Path(m.name).parts
            if len(delar) < 2:
                continue
            dict_id = delar[-2]
            try:
                version = int(Path(m.name).stem.lstrip("v"))
            except ValueError:
                continue
            ordbocker.setdefault(dict_id, {})[version] = \
                tar.extractfile(m).read()

    if not ordbocker:
        raise UnpackError(
            f"Inga ordböcker i {arkiv}. Utan dem går krypterade eller "
            "komprimerade paket inte att packa upp — och det är hela "
            "poängen med exporten. Kontrollera att den skapades med "
            "/api/v1/gdpr/export efter 2026-08-23."
        )
    return {"ordbocker": ordbocker, "manifest": manifest}


def las_header(rå: bytes) -> dict:
    """Tolka paketets header. Format v2.

    HÄR LIGGER DEN VERKLIGA RISKEN med en fristående uppackare: om
    formatet ändras och den här filen inte uppdateras, packar den upp fel
    utan att säga till. Därför avvisas okända versioner i stället för att
    gissas.
    """
    if len(rå) < 5:
        raise UnpackError("För kort för att vara ett paket.")

    if rå[:4] == V1_MAGIC:
        raise UnpackError(
            "Detta är ett v1-paket. Den här uppackaren kan v2. "
            "v1 användes före augusti 2026."
        )

    if (rå[0] & 0xF0) != V2_MARKER:
        raise UnpackError(
            "Detta är inget CAP-Shield-paket (fel markör).")

    version = rå[0] & 0x0F
    if version != 2:
        raise UnpackError(
            f"Paketformat v{version}. Denna uppackare kan v2 — den "
            "gissar hellre inte. Hämta en nyare uppackare från "
            "https://cap-shield-robin.fly.dev/cap_unpack.py"
        )

    f = rå[1]
    i = 2

    if f & FLAGG_NUMERISK_ORDBOK:
        _, i = _las_varint(rå, i)          # ordbokskod
    else:
        n, i = _las_varint(rå, i)
        i += n                             # ordboksnamn

    _, i = _las_varint(rå, i)              # ordboksversion

    kuvert = b""
    if f & FLAGG_KRYPTERAD:
        n, i = _las_varint(rå, i)
        kuvert = rå[i:i + n]
        i += n

    nyttolast_len, i = _las_varint(rå, i)
    if not f & FLAGG_INGEN_CRC:
        i += 4                             # CRC32

    return {
        "version": version,
        "komprimerad": bool(f & FLAGG_KOMPRIMERAD),
        "krypterad": bool(f & FLAGG_KRYPTERAD),
        "bunt": bool(f & FLAGG_BUNT),
        "kuvert": kuvert,
        "nyttolast": rå[i:i + nyttolast_len],
    }


def packa_upp(rå: bytes, ordbocker: dict, nyckelring: Path | None = None):
    """Packa upp ett paket. Returnerar texten, eller en lista vid bunt."""
    import zstandard as zstd

    h = las_header(rå)
    kropp = h["nyttolast"]

    if h["krypterad"]:
        if nyckelring is None:
            raise UnpackError(
                "Paketet är KRYPTERAT och ingen nyckelring angavs.\n\n"
                "Nyckelringen lämnar aldrig servern automatiskt — den "
                "måste ha säkerhetskopierats separat (cap_backup.py). "
                "Utan den går paketet inte att läsa, av ingen, någonsin. "
                "Det är avsikten med kryptering."
            )
        raise UnpackError(
            "Krypterade paket kräver nyckelringen och kuvertformatets "
            "logik. Kör cap_crypto_aes.py ur din backup mot detta paket "
            "— den filen kan de tre kuvertformaten."
        )

    if not h["komprimerad"]:
        # Rå nyttolast: komprimeringen lönade sig inte.
        return kropp.decode("utf-8", errors="replace")

    # Vilken ordbok? Utan metadata i paketet provar vi alla — de är få,
    # och fel ordbok ger ett fel snarare än fel text.
    fel = []
    for dict_id, versioner in ordbocker.items():
        for v, data in sorted(versioner.items(), reverse=True):
            try:
                d = zstd.ZstdCompressionDict(data)
                text = zstd.ZstdDecompressor(dict_data=d).decompress(
                    kropp, max_output_size=50_000_000)
                return (_dela_bunt(text) if h["bunt"]
                        else text.decode("utf-8"))
            except Exception as exc:
                fel.append(f"{dict_id} v{v}: {type(exc).__name__}")

    raise UnpackError(
        "Ingen ordbok i exporten kunde packa upp paketet.\n"
        "Provade: " + ", ".join(fel[:6]) +
        ("…" if len(fel) > 6 else "")
    )


def _dela_bunt(rå: bytes) -> list[str]:
    """Dela en uppackad bunt i sina meddelanden.

    Formatet är längdprefix om fyra byte per meddelande — samma som
    cap_batch.frame_messages. Läses här på nytt för att hålla filen
    fristående.
    """
    ut, i = [], 0
    while i + 4 <= len(rå):
        n = int.from_bytes(rå[i:i + 4], "big")
        i += 4
        if i + n > len(rå):
            raise UnpackError("Bunten är trunkerad.")
        ut.append(rå[i:i + n].decode("utf-8", errors="replace"))
        i += n
    return ut


def main() -> None:
    ap = argparse.ArgumentParser(
        description="Packa upp CAP-Shield-paket utan gatewayen")
    ap.add_argument("--export", required=True,
                    help="Ditt exportarkiv med ordböckerna")
    ap.add_argument("--paket", help="Katalog med .cap-filer")
    ap.add_argument("--ett", help="En enskild .cap-fil")
    ap.add_argument("--nyckelring", help="Katalog med keys/ ur din backup")
    a = ap.parse_args()

    e = las_export(Path(a.export))
    ordbocker = e["ordbocker"]
    antal = sum(len(v) for v in ordbocker.values())
    print(f"Läste {antal} ordboksversioner ur exporten:")
    for d, v in sorted(ordbocker.items()):
        print(f"  {d:<20} v{min(v)}–v{max(v)}  ({len(v)} st)")
    print()

    nyckelring = Path(a.nyckelring) if a.nyckelring else None

    filer = []
    if a.ett:
        filer = [Path(a.ett)]
    elif a.paket:
        filer = sorted(Path(a.paket).rglob("*.cap"))
    else:
        print("Ingen fil angiven. Ordböckerna finns och går att använda.")
        print("Ange --paket KATALOG eller --ett FIL.")
        return

    if not filer:
        print("Hittade inga .cap-filer.")
        return

    ok = fel = 0
    for f in filer:
        try:
            r = packa_upp(f.read_bytes(), ordbocker, nyckelring)
            ok += 1
            if len(filer) == 1:
                if isinstance(r, list):
                    print(f"Bunt med {len(r)} meddelanden:\\n")
                    for i, m in enumerate(r[:20], 1):
                        print(f"  {i:>3}. {m[:100]}")
                    if len(r) > 20:
                        print(f"  … och {len(r) - 20} till")
                else:
                    print(r)
        except UnpackError as exc:
            fel += 1
            print(f"  {f.name}: {exc}")

    if len(filer) > 1:
        print(f"\\n{ok} uppackade, {fel} misslyckades.")
    if ok:
        print("\\nDetta skedde utan gatewayen, utan nätverk och utan någon "
              "annan del av systemet.")


if __name__ == "__main__":
    main()
