#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Odoline
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Read an Odoline `.odoline` backup file (format v1) without the Odoline app.

Usage: python3 odoline_read.py BACKUP [--out DIR] [--csv]

Needs Python 3.9 or newer and two packages: pip install cryptography argon2-cffi

The passphrase comes from the ODOLINE_PASSPHRASE environment variable; if it is
not set, the script asks for it on the terminal (without echoing it). The script
writes DIR/backup.json (schema, createdAt, tables), the receipt photos into
DIR/photos/ and, with --csv, one CSV file per table. DIR defaults to the backup
file name without its extension.

Each photo is saved as <id>.<ext>, with the extension taken from the image's
first bytes (JPEG, PNG, GIF, WebP or HEIC). If the format is not recognised, or
the id is not a safe file name, the photo keeps its original file name.

Exit codes: 0 done; 2 wrong passphrase; 3 damaged or unsupported file;
4 cannot run (missing package, wrong options, output folder already has files,
or the output folder cannot be written).
The output folder must be new or empty: the script never overwrites anything.
Errors are printed as a single line on stderr, with no traceback, and the
passphrase is never printed.
"""
from __future__ import annotations

import argparse
import base64
import binascii
import csv
import getpass
import json
import os
import re
import struct
import sys
import zlib
from pathlib import Path

MAGIC = b'ODOLBK01'
FORMAT_VERSION = 1
# Fixed Argon2id parameters of v1. A header with other values is rejected (this
# also stops a crafted header from asking for a huge amount of memory).
KDF_MEMORY_KIB = 19456
KDF_ITERATIONS = 2
KDF_PARALLELISM = 1
KEY_LENGTH = 32
SALT_LENGTH = 16
NONCE_LENGTH = 12
TAG_LENGTH = 16
PREFIX_LENGTH = 12  # 8-byte magic + header length as big-endian uint32
MAX_HEADER_BYTES = 4 * 1024
# Cap on the decompressed JSON, the same as the app (384 MiB): guards against gzip bombs.
MAX_DECOMPRESSED_BYTES = 384 * 1024 * 1024
GZIP_HEAD = bytes([0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00])
MIN_GZIP_LENGTH = 20
PHOTO_NAME = re.compile(r'[A-Za-z0-9-]+\.[A-Za-z0-9]{1,8}')
PHOTO_ID = re.compile(r'[A-Za-z0-9-]{1,128}')
# Image formats recognised from their first bytes: (offset, magic) pairs that must all match.
IMAGE_SIGNATURES = (
    ('jpg', ((0, b'\xff\xd8\xff'),)),
    ('png', ((0, b'\x89PNG'),)),
    ('gif', ((0, b'GIF8'),)),
    ('webp', ((0, b'RIFF'), (8, b'WEBP'))),
    ('heic', ((0, b'\x00\x00\x00'), (4, b'ftypheic'))),
)
TABLE_NAME = re.compile(r'[A-Za-z0-9_]{1,64}')

EXIT_OK = 0
EXIT_WRONG_PASSPHRASE = 2
EXIT_BAD_FILE = 3
EXIT_CANNOT_RUN = 4


class ReaderError(Exception):
    """An expected failure: a one-line message (never containing the passphrase) and an exit code."""

    def __init__(self, message: str, code: int):
        super().__init__(message)
        self.code = code


def corrupt(message: str = 'the file is damaged or is not an Odoline backup') -> ReaderError:
    return ReaderError(message, EXIT_BAD_FILE)


def _b64(value, length: int) -> bytes:
    if not isinstance(value, str):
        raise corrupt()
    try:
        raw = base64.b64decode(value, validate=True)
    except (binascii.Error, ValueError):
        raise corrupt()
    if len(raw) != length:
        raise corrupt()
    return raw


def read_header(data: bytes) -> tuple[dict, int]:
    """Read the plain part of the file: (header, prefix length = magic + length + header)."""
    if len(data) < PREFIX_LENGTH or data[:8] != MAGIC:
        raise corrupt()
    (header_length,) = struct.unpack('>I', data[8:12])
    if (header_length == 0 or header_length > MAX_HEADER_BYTES
            or PREFIX_LENGTH + header_length + TAG_LENGTH > len(data)):
        raise corrupt()
    try:
        header = json.loads(data[PREFIX_LENGTH:PREFIX_LENGTH + header_length].decode('utf-8'))
    except (UnicodeDecodeError, ValueError):
        raise corrupt()
    if not isinstance(header, dict):
        raise corrupt()
    if header.get('v') != FORMAT_VERSION or isinstance(header.get('v'), bool):
        raise corrupt('unsupported backup version (this reader understands version 1)')
    kdf = header.get('kdf')
    if header.get('cipher') != 'aes-256-gcm' or not isinstance(kdf, dict) or kdf.get('alg') != 'argon2id':
        raise corrupt('unsupported encryption settings in this backup')
    if (kdf.get('m') != KDF_MEMORY_KIB or kdf.get('t') != KDF_ITERATIONS
            or kdf.get('p') != KDF_PARALLELISM):
        raise corrupt('unsupported key derivation settings in this backup')
    schema = header.get('schema')
    if not isinstance(schema, int) or isinstance(schema, bool) or schema < 1:
        raise corrupt()
    _b64(kdf.get('salt'), SALT_LENGTH)
    _b64(header.get('nonce'), NONCE_LENGTH)
    if not isinstance(header.get('createdAt'), str):
        raise corrupt()
    return header, PREFIX_LENGTH + header_length


def derive_key(passphrase: str, salt: bytes) -> bytes:
    """AES-256 key = Argon2id (m 19456 KiB, t 2, p 1) of the UTF-8 passphrase with the salt."""
    from argon2.low_level import Type, hash_secret_raw
    return hash_secret_raw(
        passphrase.encode('utf-8'), salt,
        time_cost=KDF_ITERATIONS, memory_cost=KDF_MEMORY_KIB,
        parallelism=KDF_PARALLELISM, hash_len=KEY_LENGTH, type=Type.ID,
    )


def _key_matches_gzip_head(key: bytes, nonce: bytes, cipher_text: bytes) -> bool:
    """True if the key is right (so the file is damaged elsewhere), False if the passphrase is wrong.

    A bad GCM tag has two causes: a wrong passphrase, or truncated or altered data.
    Decrypt (without authentication) the first 8 bytes with AES-CTR, counter
    nonce || 00000002 (big-endian), and compare them with the gzip header; the
    app uses the same rule.
    """
    from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
    if len(cipher_text) < MIN_GZIP_LENGTH:
        return True
    decryptor = Cipher(algorithms.AES(key), modes.CTR(nonce + b'\x00\x00\x00\x02')).decryptor()
    plain = decryptor.update(cipher_text[:len(GZIP_HEAD)]) + decryptor.finalize()
    if plain[:3] == GZIP_HEAD[:3]:
        return True
    same = sum(1 for a, b in zip(plain, GZIP_HEAD) if a == b)
    return same >= len(GZIP_HEAD) - 1


def _gunzip_capped(data: bytes) -> bytes:
    decompressor = zlib.decompressobj(wbits=31)
    out = bytearray()
    chunk = 16 * 1024
    try:
        for start in range(0, len(data), chunk):
            pending = data[start:start + chunk]
            while pending:
                room = MAX_DECOMPRESSED_BYTES - len(out)
                piece = decompressor.decompress(pending, room + 1)
                out += piece
                if len(out) > MAX_DECOMPRESSED_BYTES:
                    raise corrupt('the backup is larger than this reader accepts')
                pending = decompressor.unconsumed_tail
                if decompressor.eof:
                    break
            if decompressor.eof:
                break
        out += decompressor.flush()
    except zlib.error:
        raise corrupt()
    if not decompressor.eof or len(out) > MAX_DECOMPRESSED_BYTES:
        raise corrupt()
    return bytes(out)


def _parse_payload(raw: bytes) -> dict:
    try:
        payload = json.loads(raw.decode('utf-8'))
    except (UnicodeDecodeError, ValueError):
        raise corrupt()
    if not isinstance(payload, dict):
        raise corrupt()
    tables, photos = payload.get('tables'), payload.get('photos')
    if not isinstance(tables, dict) or not isinstance(photos, list):
        raise corrupt()
    for name, rows in tables.items():
        if not TABLE_NAME.fullmatch(name) or not isinstance(rows, list):
            raise corrupt()
        for row in rows:
            if not isinstance(row, dict) or not all(
                    v is None or isinstance(v, (int, float, str)) for v in row.values()):
                raise corrupt()
    decoded = []
    for photo in photos:
        if not isinstance(photo, dict):
            raise corrupt()
        name, data = photo.get('fileName'), photo.get('data')
        if (not isinstance(photo.get('id'), str) or not isinstance(name, str)
                or not PHOTO_NAME.fullmatch(name) or not isinstance(data, str)):
            raise corrupt()
        try:
            decoded.append({'id': photo['id'], 'fileName': name, 'data': base64.b64decode(data, validate=True)})
        except (binascii.Error, ValueError):
            raise corrupt()
    return {'tables': tables, 'photos': decoded}


def decrypt(data: bytes, passphrase: str) -> dict:
    """Decrypt the file: {"schema", "createdAt", "tables", "photos"}; each photo's "data" is bytes.

    Raises ReaderError (code 2 wrong passphrase, 3 damaged or unsupported file).
    """
    from cryptography.exceptions import InvalidTag
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    header, prefix_length = read_header(data)
    salt = _b64(header['kdf']['salt'], SALT_LENGTH)
    nonce = _b64(header['nonce'], NONCE_LENGTH)
    key = derive_key(passphrase, salt)
    cipher_text = data[prefix_length:]  # ciphertext + tag 16 byte
    try:
        gz = AESGCM(key).decrypt(nonce, cipher_text, data[:prefix_length])
    except InvalidTag:
        if _key_matches_gzip_head(key, nonce, cipher_text[:-TAG_LENGTH]):
            raise corrupt()
        raise ReaderError('wrong passphrase', EXIT_WRONG_PASSPHRASE)
    payload = _parse_payload(_gunzip_capped(gz))
    return {'schema': header['schema'], 'createdAt': header['createdAt'], **payload}


def _csv_columns(rows: list[dict]) -> list[str]:
    columns: list[str] = []
    for row in rows:
        for key in row:
            if key not in columns:
                columns.append(key)
    return columns


def image_extension(data: bytes) -> str | None:
    """File extension for a JPEG, PNG, GIF, WebP or HEIC image, from its first bytes; None if not recognised."""
    for ext, parts in IMAGE_SIGNATURES:
        if all(data[offset:offset + len(magic)] == magic for offset, magic in parts):
            return ext
    return None


def photo_file_names(photos: list) -> list:
    """One file name per photo: <id>.<ext> when the id is safe and the format is known, else the checked fileName.

    Names never repeat, so no photo overwrites another.
    """
    used = set()
    names = []
    for photo in photos:
        ext = image_extension(photo['data'])
        name = photo['fileName']
        if ext and PHOTO_ID.fullmatch(photo['id']) and f"{photo['id']}.{ext}" not in used:
            name = f"{photo['id']}.{ext}"
        if name in used:
            raise corrupt('two photos in the backup have the same file name')
        used.add(name)
        names.append(name)
    return names


def write_output(result: dict, out_dir: Path, as_csv: bool) -> None:
    """Write the result. Every name was checked in decrypt, so nothing can land outside out_dir."""
    names = photo_file_names(result['photos'])  # before writing anything: a clash stops the run cleanly
    out_dir.mkdir(parents=True, exist_ok=True)
    document = {'schema': result['schema'], 'createdAt': result['createdAt'], 'tables': result['tables']}
    (out_dir / 'backup.json').write_text(
        json.dumps(document, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    if result['photos']:
        (out_dir / 'photos').mkdir(exist_ok=True)
        for photo, name in zip(result['photos'], names):
            (out_dir / 'photos' / name).write_bytes(photo['data'])
    if as_csv:
        for name, rows in result['tables'].items():
            columns = _csv_columns(rows)
            with open(out_dir / f'{name}.csv', 'w', newline='', encoding='utf-8') as handle:
                writer = csv.writer(handle)
                writer.writerow(columns)
                for row in rows:
                    writer.writerow(['' if row.get(c) is None else row[c] for c in columns])


class _Parser(argparse.ArgumentParser):
    """Usage errors become one line and code 4 (argparse's own code 2 would read as "wrong passphrase")."""

    def error(self, message):
        raise ReaderError(message + ' (see --help)', EXIT_CANNOT_RUN)


def _check_dependencies() -> None:
    try:
        import cryptography.hazmat.primitives.ciphers.aead  # noqa: F401
        import argon2.low_level  # noqa: F401
    except ImportError:
        raise ReaderError('needs cryptography and argon2-cffi (pip install cryptography argon2-cffi)',
                          EXIT_CANNOT_RUN)


def _default_out_dir(backup: Path) -> Path:
    out = backup.with_suffix('')
    if out.resolve() == backup.resolve():  # no extension: do not collide with the file itself
        out = backup.with_name(backup.name + '-out')
    return out


def _check_out_dir(out_dir: Path) -> None:
    """Accept only a new or empty folder (not a symlink): nothing is overwritten and no old files linger."""
    if out_dir.is_symlink() or (out_dir.exists() and not out_dir.is_dir()):
        raise ReaderError('output folder is not a plain folder; choose another with --out', EXIT_CANNOT_RUN)
    if out_dir.is_dir() and any(out_dir.iterdir()):
        raise ReaderError('output folder already has files; choose another with --out', EXIT_CANNOT_RUN)


def main(argv: list[str] | None = None) -> int:
    parser = _Parser(description='Read an Odoline .odoline backup without the app.')
    parser.add_argument('backup', help='path of the .odoline file')
    parser.add_argument('--out', help='output folder, new or empty (default: the file name without its extension)')
    parser.add_argument('--csv', action='store_true', help='also write one CSV file per table')
    try:
        args = parser.parse_args(argv)
        _check_dependencies()
        try:
            data = Path(args.backup).read_bytes()
        except OSError:
            raise corrupt('cannot read the backup file')
        read_header(data)  # report a damaged file before asking for the passphrase
        out_dir = Path(args.out) if args.out else _default_out_dir(Path(args.backup))
        _check_out_dir(out_dir)
        passphrase = os.environ.get('ODOLINE_PASSPHRASE')
        if passphrase is None:
            passphrase = getpass.getpass('Passphrase: ')
        result = decrypt(data, passphrase)
        write_output(result, out_dir, args.csv)
    except ReaderError as error:
        print(f'odoline_read: {error}', file=sys.stderr)
        return error.code
    except (EOFError, KeyboardInterrupt):
        print('odoline_read: cancelled', file=sys.stderr)
        return EXIT_CANNOT_RUN
    except OSError:
        print('odoline_read: cannot write the output folder', file=sys.stderr)
        return EXIT_CANNOT_RUN
    print(f'Wrote {out_dir}')
    return EXIT_OK


if __name__ == '__main__':
    sys.exit(main())
