"""InFinBot authenticated report archives. Preserve the source; never seed data.

Format: a compressed ZIP encrypted as a whole with AES-256-GCM (.zip.aes).
Requires cryptography. This format opens with this script, not Finder/unzip.
"""
import argparse
import getpass
import hashlib
import io
import json
import os
from pathlib import Path
import stat
import sys
import tempfile
import uuid
import zipfile
from datetime import datetime, timezone
from decimal import Decimal

MAGIC = b"INFINBOT-ZIP-AESGCM-V1\x00"
LEGACY_MAGIC = b"FORESIXA-ZIP-AESGCM-V1\x00"
MAX_REPORT = 32 * 1024 * 1024
MAX_ARCHIVE = MAX_REPORT + 1024 * 1024
ROOT = Path(__file__).resolve().parent


def private_directory(path):
    path = Path(path).absolute()
    if path.is_symlink():
        raise ValueError("Private directory must not be a symlink")
    path.mkdir(mode=0o700, parents=True, exist_ok=True)
    info = path.stat()
    if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
        raise ValueError("Directory must be owner-owned and private (chmod 700)")
    return path


def read_regular(path, limit, private=False):
    fd = os.open(str(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
    with os.fdopen(fd, "rb") as stream:
        info = os.fstat(stream.fileno())
        if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
            raise ValueError("Owner-owned regular file required")
        if private and (info.st_mode & 0o077 or info.st_nlink != 1):
            raise ValueError("Password file requires private permissions (chmod 600), no hard links")
        if info.st_size > limit:
            raise ValueError("File exceeds supported size")
        raw = stream.read(limit + 1)
        after = os.fstat(stream.fileno())
        if len(raw) > limit or (info.st_size, info.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
            raise ValueError("File too large or changed while reading; retry a stable export")
        return raw


def checked_password(value):
    if not isinstance(value, str) or not 16 <= len(value) <= 1024 or any(c in value for c in "\r\n\x00"):
        raise ValueError("Use a private password of 16 to 1024 characters, without line breaks")
    return value.encode("utf-8")


def load_password(path=None, non_interactive=False):
    if path:
        raw = read_regular(path, 4096, private=True)
        text = raw.decode("utf-8")
        return checked_password(text[:-1] if text.endswith("\n") else text)
    if non_interactive or not sys.stdin.isatty():
        raise ValueError("A private --password-file is required for scheduled/non-interactive runs")
    return checked_password(getpass.getpass("Private archive password (hidden): "))


def create_password_file(path):
    if not sys.stdin.isatty():
        raise ValueError("Password setup requires an interactive terminal")
    path = Path(path).absolute()
    private_directory(path.parent)
    value = getpass.getpass("Create a NEW archive password (16+ characters; hidden): ")
    password = checked_password(value)
    if value != getpass.getpass("Confirm archive password (hidden): "):
        raise ValueError("Password confirmation does not match")
    # No existing password is ever replaced. Keep a separate private recovery copy.
    publish(path, password + b"\n")
    return path


def key(password, salt):
    from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
    return Scrypt(salt=salt, length=32, n=2**17, r=8, p=1).derive(password)


def seal(compressed, password):
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    salt, nonce = os.urandom(16), os.urandom(12)
    header = MAGIC + salt + nonce
    return header + AESGCM(key(password, salt)).encrypt(nonce, compressed, header)


def reject_constant(value):
    raise ValueError("Nonfinite JSON is unsupported")


def unique_object(pairs):
    result = {}
    for name, value in pairs:
        if name in result:
            raise ValueError("Duplicate JSON field is unsupported")
        result[name] = value
    return result


def validate_report(raw):
    rows = json.loads(raw.decode("utf-8"), parse_float=Decimal, parse_constant=reject_constant,
                      object_pairs_hook=unique_object)
    if not isinstance(rows, list) or not rows or not all(isinstance(row, dict) and row for row in rows):
        raise ValueError("A nonempty JSON array of report records is required; no synthetic file created")
    return len(rows)


def unseal(blob, password):
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    from cryptography.exceptions import InvalidTag
    length = len(MAGIC) + 28
    if len(blob) > MAX_ARCHIVE or len(blob) < length + 16 or not blob.startswith((MAGIC,LEGACY_MAGIC)):
        raise ValueError("Unsupported or truncated archive")
    header = blob[:length]
    salt, nonce = header[len(MAGIC):len(MAGIC)+16], header[-12:]
    try:
        compressed = AESGCM(key(password, salt)).decrypt(nonce, blob[length:], header)
    except InvalidTag:
        raise ValueError("Archive authentication failed: wrong password or altered archive") from None
    with zipfile.ZipFile(io.BytesIO(compressed)) as archive:
        names = archive.namelist()
        if sorted(names) != ["manifest.json", "report.json"]:
            raise ValueError("Unexpected or duplicate archive members")
        if archive.getinfo("report.json").file_size > MAX_REPORT or archive.getinfo("manifest.json").file_size > 8192:
            raise ValueError("Unsupported expanded archive size")
        raw = archive.read("report.json")
        manifest = json.loads(archive.read("manifest.json"), object_pairs_hook=unique_object)
    if not isinstance(manifest, dict) or manifest.get("source_sha256") != hashlib.sha256(raw).hexdigest():
        raise ValueError("Archived source digest mismatch")
    if manifest.get("source_bytes") != len(raw) or manifest.get("record_count") != validate_report(raw):
        raise ValueError("Archived source metadata mismatch")
    return raw, manifest


def publish(path, data, verify=None):
    """Private, fsynced, no-overwrite publication of caller-supplied bytes."""
    path = Path(path).absolute()
    directory = private_directory(path.parent)
    fd, temporary = tempfile.mkstemp(prefix=".infinbot-pending-", dir=directory)
    try:
        with os.fdopen(fd, "wb") as stream:
            os.fchmod(stream.fileno(), 0o600)
            stream.write(data)
            stream.flush()
            os.fsync(stream.fileno())
        if verify:
            verify(read_regular(temporary, MAX_ARCHIVE))
        os.link(temporary, path)  # Existing destinations are never replaced.
        directory_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
        try:
            os.fsync(directory_fd)
        finally:
            os.close(directory_fd)
    finally:
        os.unlink(temporary)


def archive_report(source, archive_dir, password):
    source = Path(source).absolute()
    raw = read_regular(source, MAX_REPORT)
    count = validate_report(raw)
    manifest = {"format": "INFINBOT_REPORT_WORKPAPER_V1", "created_at": datetime.now(timezone.utc).isoformat(),
                "source_name": source.name, "source_sha256": hashlib.sha256(raw).hexdigest(),
                "source_bytes": len(raw), "record_count": count,
                "provenance": "USER_SUPPLIED_EXPORT_NOT_BROKER_VERIFIED", "irs_compliance_verified": False}
    compressed = io.BytesIO()
    with zipfile.ZipFile(compressed, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipped:
        zipped.writestr("report.json", raw)
        zipped.writestr("manifest.json", json.dumps(manifest))
    encrypted = seal(compressed.getvalue(), password)
    directory = private_directory(archive_dir)
    filename = "infinbot_tax_archive_" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ_") + uuid.uuid4().hex + ".zip.aes"
    target = directory / filename

    def verify(candidate):
        recovered, _ = unseal(candidate, password)
        if recovered != raw:
            raise ValueError("Restored bytes differ from the source snapshot")

    publish(target, encrypted, verify=verify)
    verify(read_regular(target, MAX_ARCHIVE))
    return {"status": "ARCHIVE_VERIFIED", "archive_path": str(target),
            "encryption": "AES-256-GCM", "kdf": "scrypt-N131072-r8-p1",
            "archive_sha256": hashlib.sha256(encrypted).hexdigest(),
            "source_snapshot_sha256": manifest["source_sha256"], "record_count": count,
            "decryption_and_byte_recovery_verified": True, "source_deleted": False,
            "source_modified": False, "irs_compliance_verified": False,
            "offsite_copy_verified": False, "retention_pruning_performed": False}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    actions = parser.add_mutually_exclusive_group()
    actions.add_argument("--verify", metavar="ARCHIVE")
    actions.add_argument("--restore", metavar="ARCHIVE")
    actions.add_argument("--create-password-file", metavar="NEW_PRIVATE_FILE")
    parser.add_argument("--restore-to", help="New file in a private directory; never overwrite")
    parser.add_argument("--source", default=str(ROOT / "automated_irs_form_8949.json"))
    parser.add_argument("--archive-dir", default=str(ROOT / "tax_archive_vault"))
    parser.add_argument("--password-file", help="Owner-owned mode-600 file; password is never an argument")
    parser.add_argument("--non-interactive", action="store_true")
    args = parser.parse_args()
    try:
        if args.restore_to and not args.restore:
            raise ValueError("--restore-to requires --restore")
        if args.create_password_file:
            create_password_file(args.create_password_file)
            result = {"status": "PRIVATE_PASSWORD_FILE_CREATED", "password_displayed": False}
        else:
            if not (args.verify or args.restore):
                # Missing export blocks before password prompt, key derivation or output creation.
                validate_report(read_regular(args.source, MAX_REPORT))
            password = load_password(args.password_file, args.non_interactive)
            if args.verify or args.restore:
                raw, manifest = unseal(read_regular(args.verify or args.restore, MAX_ARCHIVE), password)
                if args.restore:
                    if not args.restore_to:
                        raise ValueError("Restoration requires an explicit --restore-to destination")
                    publish(args.restore_to, raw)
                    if read_regular(args.restore_to, MAX_REPORT) != raw:
                        raise ValueError("Restored file failed byte verification")
                result = {"status": "RESTORE_VERIFIED" if args.restore else "ARCHIVE_VERIFIED",
                          "source_snapshot_sha256": manifest["source_sha256"], "source_deleted": False,
                          "irs_compliance_verified": False}
            else:
                result = archive_report(args.source, args.archive_dir, password)
        print(json.dumps(result, indent=2))
        return 0
    except ImportError:
        reason = "Install cryptography in the Python environment used by this script"
    except FileNotFoundError:
        reason = "Required input file is missing; no source or mock report created"
    except UnicodeError:
        reason = "Input must be valid UTF-8; no source or mock report created"
    except ValueError as exc:
        reason = str(exc)
    except (OSError, zipfile.BadZipFile, EOFError, RecursionError):
        # Never echo exception details, passwords, report contents, or provider responses.
        reason = "Blocked: check existing JSON export, private file permissions, password, archive integrity and output path"
    print(json.dumps({"status": "ARCHIVE_BLOCKED", "reason": reason,
                      "source_deleted": False, "irs_compliance_verified": False}, indent=2))
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
