"""Forensic archive — evidence preservation packages.

Creates a self-contained, verifiable evidence package from a Census manifest:

- **Manifest database** — complete file inventory with hashes
- **SHA256SUMS** — verification checksums for all archive contents
- **Chain of custody** — examiner, case ID, notes, timestamps
- **System metadata** — tool version, OS, hostname, timestamps
- **Integrity seal** — HMAC-SHA256 seal of the manifest (if sealed)

Designed for long-term forensic storage. The archive format follows
EnCase/FTK conventions for evidence packaging while remaining
self-verifiable using standard Unix tools.

File format: ``evidence-YYYY-MM-DD.census.zip``
"""

from __future__ import annotations

import hashlib
import io
import json
import logging
import os
import platform
import time
import zipfile
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

from . import __version__

logger = logging.getLogger(__name__)

MAX_ARCHIVE_SIZE = 500 * 1024 * 1024  # 500 MB — decompression bomb guard


@dataclass
class ChainOfCustody:
    """Metadata for forensic chain-of-custody documentation."""

    examiner: str = ""
    case_id: str = ""
    notes: str = ""
    organization: str = ""


@dataclass
class ArchiveResult:
    """Result of an archive creation."""

    path: Path
    manifest_entries: int
    attested_count: int
    archive_size: int
    archive_hash: str
    chain_of_custody: ChainOfCustody = field(default_factory=ChainOfCustody)


def _sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def _sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def _now_iso() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _system_metadata() -> dict[str, Any]:
    return {
        "census_version": __version__,
        "python_version": platform.python_version(),
        "os": platform.system(),
        "os_version": platform.version(),
        "hostname": platform.node(),
        "architecture": platform.machine(),
        "created_at": _now_iso(),
        "pid": os.getpid(),
    }


def create_archive(
    manifest_path: Path,
    output: Path,
    *,
    chain_of_custody: ChainOfCustody | None = None,
    include_seal: bool = True,
    compress: bool = True,
) -> ArchiveResult:
    """Create a forensic evidence archive from a Census manifest.

    The archive is a ZIP file containing:
    - ``manifest.db`` — the SQLite manifest (copy)
    - ``metadata.json`` — system info, timestamps, Census version
    - ``chain_of_custody.json`` — examiner, case ID, notes (if provided)
    - ``inventory.json`` — full list of entries with hashes
    - ``SHA256SUMS`` — checksums of all archive contents
    - ``manifest.db.seal`` — HMAC seal (if present)

    Parameters
    ----------
    manifest_path
        Path to the SQLite manifest database.
    output
        Output ZIP path. Recommended: ``evidence-YYYY-MM-DD.census.zip``
    chain_of_custody
        Optional examiner/case metadata for forensic documentation.
    include_seal
        If True and a ``.seal`` sidecar exists, include it in the archive.
    compress
        If True, use ZIP deflate compression.
    """
    from .manifest import Manifest
    from .seal import seal_path_for

    manifest_path = manifest_path.resolve()
    if not manifest_path.exists():
        raise FileNotFoundError(f"Manifest not found: {manifest_path}")

    manifest = Manifest.load(manifest_path)
    try:
        total = manifest.total
        attested = manifest.attested_count
        root_dir = manifest.root_dir if hasattr(manifest, "root_dir") else ""
        entries_data = []
        for entry in manifest.entries.values():
            entries_data.append({
                "hash_hex": entry.hash_hex,
                "path": entry.path,
                "size": entry.size,
                "mtime": entry.mtime,
                "attested": entry.attested,
                "attestation_id": entry.attestation_id,
                "attested_at": entry.attested_at,
                "owner": entry.owner,
                "file_group": entry.file_group,
                "file_mode": entry.file_mode,
            })
    finally:
        manifest.close()

    coc = chain_of_custody or ChainOfCustody()
    metadata = _system_metadata()
    metadata["manifest_path"] = str(manifest_path)
    metadata["manifest_entries"] = total
    metadata["attested_count"] = attested
    metadata["root_dir"] = root_dir

    compression = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
    checksums: dict[str, str] = {}

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", compression=compression) as zf:
        manifest_bytes = manifest_path.read_bytes()
        zf.writestr("manifest.db", manifest_bytes)
        checksums["manifest.db"] = _sha256_bytes(manifest_bytes)

        meta_bytes = json.dumps(metadata, indent=2, default=str).encode()
        zf.writestr("metadata.json", meta_bytes)
        checksums["metadata.json"] = _sha256_bytes(meta_bytes)

        coc_bytes = json.dumps(asdict(coc), indent=2).encode()
        zf.writestr("chain_of_custody.json", coc_bytes)
        checksums["chain_of_custody.json"] = _sha256_bytes(coc_bytes)

        inv_bytes = json.dumps(entries_data, indent=2, default=str).encode()
        zf.writestr("inventory.json", inv_bytes)
        checksums["inventory.json"] = _sha256_bytes(inv_bytes)

        seal_path = seal_path_for(manifest_path)
        if include_seal and seal_path.exists():
            seal_bytes = seal_path.read_bytes()
            zf.writestr("manifest.db.seal", seal_bytes)
            checksums["manifest.db.seal"] = _sha256_bytes(seal_bytes)

        sums_lines = "\n".join(
            f"{chk}  {name}" for name, chk in sorted(checksums.items())
        ) + "\n"
        zf.writestr("SHA256SUMS", sums_lines.encode())

    archive_bytes = buf.getvalue()
    output.parent.mkdir(parents=True, exist_ok=True)

    fd = os.open(str(output), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(archive_bytes)
    except BaseException:
        try:
            output.unlink()
        except OSError as exc:
            logger.warning("Cannot remove partial archive %s: %s", output, exc)
        raise

    return ArchiveResult(
        path=output,
        manifest_entries=total,
        attested_count=attested,
        archive_size=len(archive_bytes),
        archive_hash=_sha256_bytes(archive_bytes),
        chain_of_custody=coc,
    )


def verify_archive(archive_path: Path) -> dict[str, Any]:
    """Verify the integrity of a Census forensic archive.

    Checks SHA256SUMS against actual file contents within the ZIP.
    Returns a dict with verification results.
    """
    archive_path = archive_path.resolve()
    if not archive_path.exists():
        raise FileNotFoundError(f"Archive not found: {archive_path}")

    try:
        file_size = archive_path.stat().st_size
    except OSError as exc:
        raise FileNotFoundError(f"Cannot stat archive: {exc}") from exc
    if file_size > MAX_ARCHIVE_SIZE:
        raise ValueError(
            f"Archive too large ({file_size:,} bytes, max {MAX_ARCHIVE_SIZE:,}). "
            f"Refusing to process — possible decompression bomb."
        )

    with zipfile.ZipFile(archive_path, "r") as zf:
        names = zf.namelist()
        if "SHA256SUMS" not in names:
            return {"valid": False, "error": "Missing SHA256SUMS", "files": {}}

        sums_text = zf.read("SHA256SUMS").decode()
        expected: dict[str, str] = {}
        for line in sums_text.strip().splitlines():
            parts = line.split("  ", 1)
            if len(parts) == 2:
                expected[parts[1]] = parts[0]

        results: dict[str, dict[str, str | bool]] = {}
        all_valid = True
        for name, expected_hash in expected.items():
            if name not in names:
                results[name] = {"expected": expected_hash, "actual": "", "valid": False}
                all_valid = False
                continue
            actual = _sha256_bytes(zf.read(name))
            valid = actual == expected_hash
            results[name] = {"expected": expected_hash, "actual": actual, "valid": valid}
            if not valid:
                all_valid = False

    return {
        "valid": all_valid,
        "archive": str(archive_path),
        "archive_hash": _sha256_file(archive_path),
        "files": results,
    }
