#!/usr/bin/env python3
"""Create a ZIP bundle of retained legacy Windows first-iteration evidence."""

from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DOCS = (
    ROOT / "docs" / "legacy-windows-first-iteration-runbook.md",
    ROOT / "docs" / "ship-notes" / "2026-07-08-legacy-windows-first-iteration.md",
)
DEFAULT_DOC_LABELS = (
    "docs/legacy-windows-first-iteration-runbook.md",
    "docs/ship-notes/2026-07-08-legacy-windows-first-iteration.md",
)
CHECKER_PATH = ROOT / "scripts" / "legacy-windows-lab-evidence-check.py"
OPTIONAL_COLLECT_EXTRA_FILES = (
    "LEGACY-LAB-NEXT-STEPS.txt",
    "legacy-windows-lab-run-output.txt",
    "operator-context.json",
)
REQUIRED_WIN10_PROBE_FIELDS = (
    "bios_version",
    "manufacturer",
    "model",
    "processor_name",
    "total_ram_gb",
)


def _load_evidence_checker():
    spec = importlib.util.spec_from_file_location("legacy_windows_lab_evidence_check", CHECKER_PATH)
    if spec is None or spec.loader is None:
        raise RuntimeError(f"cannot load evidence checker: {CHECKER_PATH}")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


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


def _require_file(path: Path, role: str) -> str | None:
    if not path.exists() or not path.is_file() or path.stat().st_size <= 0:
        return f"missing required artifact for {role}: {path}"
    return None


def _load_json(path: Path) -> tuple[dict[str, Any] | None, str | None]:
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        return None, f"{path.name}: cannot read JSON: {exc}"
    if not isinstance(payload, dict):
        return None, f"{path.name}: root JSON value must be an object"
    return payload, None


def _classify_legacy_os(smoke: dict[str, Any]) -> str:
    hay = " ".join(
        str(smoke.get(key) or "").lower()
        for key in ("os_caption", "os_version", "os_service_pack", "service_pack")
    )
    is_sp1 = "service pack 1" in hay or "sp1" in hay or "6.1.7601" in hay
    if "server 2008 r2" in hay and is_sp1:
        return "server_2008_r2"
    if ("windows 7" in hay or "windows nt 6.1" in hay) and is_sp1:
        return "windows_7_sp1"
    return "unknown"


def _validate_smoke_collect_hostname(smoke_path: Path, collect_manifest_path: Path) -> list[str]:
    smoke, smoke_error = _load_json(smoke_path)
    collect, collect_error = _load_json(collect_manifest_path)
    errors = [error for error in (smoke_error, collect_error) if error]
    if errors:
        return errors
    assert smoke is not None
    assert collect is not None

    raw_smoke_hostname = smoke.get("hostname")
    raw_collect_hostname = collect.get("hostname")
    smoke_hostname = raw_smoke_hostname.strip() if isinstance(raw_smoke_hostname, str) else ""
    collect_hostname = raw_collect_hostname.strip() if isinstance(raw_collect_hostname, str) else ""
    if smoke_hostname and collect_hostname and smoke_hostname.lower() != collect_hostname.lower():
        return [
            f"{collect_manifest_path.name}: collector manifest hostname must match smoke hostname: {smoke_hostname}"
        ]
    raw_smoke_os_kind = smoke.get("os_kind")
    raw_collect_os_kind = collect.get("os_kind")
    smoke_os_kind = raw_smoke_os_kind.strip() if isinstance(raw_smoke_os_kind, str) else ""
    collect_os_kind = raw_collect_os_kind.strip() if isinstance(raw_collect_os_kind, str) else ""
    if smoke_os_kind and collect_os_kind and smoke_os_kind != collect_os_kind:
        return [
            f"{collect_manifest_path.name}: collector manifest os_kind must match smoke os_kind: {smoke_os_kind}"
        ]
    return []


def _validate_collect_root_smoke_hostname(smoke_path: Path, collect_root: Path) -> list[str]:
    collect_smoke_path = collect_root / "legacy-windows-lab-smoke.json"
    smoke, smoke_error = _load_json(smoke_path)
    collect_smoke, collect_smoke_error = _load_json(collect_smoke_path)
    errors = [error for error in (smoke_error, collect_smoke_error) if error]
    if errors:
        return errors
    assert smoke is not None
    assert collect_smoke is not None

    raw_smoke_hostname = smoke.get("hostname")
    raw_collect_smoke_hostname = collect_smoke.get("hostname")
    smoke_hostname = raw_smoke_hostname.strip() if isinstance(raw_smoke_hostname, str) else ""
    collect_smoke_hostname = (
        raw_collect_smoke_hostname.strip()
        if isinstance(raw_collect_smoke_hostname, str)
        else ""
    )
    if smoke_hostname and collect_smoke_hostname and smoke_hostname.lower() != collect_smoke_hostname.lower():
        return [
            f"{collect_smoke_path.name}: collect-root smoke hostname must match lab smoke hostname: {smoke_hostname}"
        ]
    if _sha256(smoke_path) != _sha256(collect_smoke_path):
        return [
            f"{collect_smoke_path.name}: collect-root smoke artifact must match lab smoke artifact"
        ]
    return []


def _artifact_entry(role: str, source: Path, archive_path: str) -> dict[str, Any]:
    return {
        "role": role,
        "source_path": str(source),
        "archive_path": archive_path,
        "sha256": _sha256(source),
        "size_bytes": source.stat().st_size,
    }


def _collect_file_members(checker: Any, manifest_path: Path, collect_root: Path) -> list[tuple[str, Path, str]]:
    payload, _file_set, errors = checker._collector_manifest_files(manifest_path)
    if errors:
        raise ValueError("\n".join(errors))
    files = payload.get("files") if isinstance(payload, dict) else []
    if not isinstance(files, list):
        return []

    members: list[tuple[str, Path, str]] = []
    seen: set[str] = set()
    for raw_name in files:
        name = str(raw_name or "").strip()
        if not name or name in seen:
            continue
        seen.add(name)
        source = collect_root / name
        archive_path = f"artifacts/legacy-windows-lab-evidence/{name}"
        members.append((f"collect_file:{name}", source, archive_path))
    return members


def _collect_extra_members(collect_root: Path, existing_archive_paths: set[str]) -> list[tuple[str, Path, str]]:
    members: list[tuple[str, Path, str]] = []
    for name in OPTIONAL_COLLECT_EXTRA_FILES:
        role = f"collect_extra:{name}"
        archive_path = f"artifacts/legacy-windows-lab-evidence/{name}"
        if archive_path in existing_archive_paths:
            continue
        source = collect_root / name
        if not source.exists() or not source.is_file() or source.stat().st_size <= 0:
            continue
        members.append((role, source, archive_path))
    return members


def _validate_build_manifest(path: Path) -> list[str]:
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        return [f"{path.name}: cannot read build manifest JSON: {exc}"]
    if not isinstance(payload, dict):
        return [f"{path.name}: build manifest root JSON value must be an object"]

    errors: list[str] = []
    if payload.get("schema") != "legacy-windows-first-iteration-build-manifest":
        errors.append(f"{path.name}: build manifest schema mismatch")
    if "go1.20" not in str(payload.get("legacy_go_version") or ""):
        errors.append(f"{path.name}: build manifest legacy_go_version must be Go 1.20")
    raw_artifacts = payload.get("artifacts")
    artifacts: dict[str, dict[str, Any]] = {}
    if not isinstance(raw_artifacts, list):
        errors.append(f"{path.name}: build manifest artifacts must be a list")
    else:
        seen_artifacts: set[str] = set()
        for item in raw_artifacts:
            if not isinstance(item, dict):
                errors.append(f"{path.name}: build manifest artifact entries must be objects")
                continue
            artifact_path = str(item.get("path") or "")
            if artifact_path in seen_artifacts:
                errors.append(f"{path.name}: build manifest duplicate artifact path: {artifact_path}")
            seen_artifacts.add(artifact_path)
            artifacts[artifact_path] = item
    recover = artifacts.get("bin/sk-recover-legacy.exe")
    if not recover:
        errors.append(f"{path.name}: build manifest missing sk-recover-legacy.exe artifact metadata")
    else:
        raw_sha = recover.get("sha256")
        if not isinstance(raw_sha, str) or len(raw_sha.strip()) != 64:
            errors.append(
                f"{path.name}: build manifest sk-recover-legacy.exe sha256 must be a 64-character hex string"
            )
        else:
            try:
                int(raw_sha, 16)
            except ValueError:
                errors.append(
                    f"{path.name}: build manifest sk-recover-legacy.exe sha256 must be a 64-character hex string"
                )
        raw_size = recover.get("size_bytes")
        if isinstance(raw_size, bool) or not isinstance(raw_size, int) or raw_size <= 0:
            errors.append(
                f"{path.name}: build manifest sk-recover-legacy.exe size_bytes must be a positive integer"
            )
    return errors


def _probe_context_value(payload: dict[str, Any], field: str) -> str:
    value = payload.get(field)
    if isinstance(value, str):
        return value.strip()
    if field == "total_ram_gb" and isinstance(value, (int, float)) and not isinstance(value, bool):
        return str(value)
    return ""


def _positive_probe_ram_gb(payload: dict[str, Any]) -> bool:
    value = payload.get("total_ram_gb")
    if isinstance(value, bool):
        return False
    if isinstance(value, (int, float)):
        return value > 0
    if isinstance(value, str):
        try:
            return float(value.strip()) > 0
        except ValueError:
            return False
    return False


def _validate_win10_probe(path: Path) -> list[str]:
    payload, error = _load_json(path)
    if error:
        return [error]
    assert payload is not None
    errors: list[str] = []
    if _probe_context_value(payload, "verdict") != "candidate":
        errors.append(f"{path.name}: verdict must be candidate")
    missing = [
        field for field in REQUIRED_WIN10_PROBE_FIELDS
        if not _probe_context_value(payload, field)
    ]
    if missing:
        errors.append(f"{path.name}: missing hardware context: {', '.join(missing)}")
    elif not _positive_probe_ram_gb(payload):
        errors.append(f"{path.name}: total_ram_gb must be greater than 0")
    return errors


def build_bundle(args: argparse.Namespace) -> list[str]:
    errors: list[str] = []
    required_artifacts = [
        ("lab_smoke", args.lab_smoke, "artifacts/legacy-windows-lab-smoke.json"),
    ]
    optional_artifacts = [
        ("collect_manifest", args.collect_manifest, "artifacts/legacy-windows-lab-collector-manifest.json"),
        ("build_manifest", args.build_manifest, "artifacts/legacy-windows-first-iteration-build-manifest.json"),
        ("win10_upgrade_status", args.win10_upgrade_status, "artifacts/win10_upgrade.status.json"),
        ("win10_upgrade_log", args.win10_upgrade_log, "artifacts/win10_upgrade.log"),
        ("win10_probe", args.win10_probe, "artifacts/win10-probe.json"),
        ("command_history", args.command_history, "artifacts/command-history.json"),
    ]

    for role, path, _archive_path in required_artifacts:
        errors_for_file = _require_file(path, role)
        if errors_for_file:
            errors.append(errors_for_file)
    for role, path, _archive_path in optional_artifacts:
        if path is not None:
            errors_for_file = _require_file(path, role)
            if errors_for_file:
                errors.append(errors_for_file)
    for doc in DEFAULT_DOCS:
        errors_for_file = _require_file(doc, "release_doc")
        if errors_for_file:
            errors.append(errors_for_file)
    if errors:
        return errors

    checker = _load_evidence_checker()
    errors.extend(checker.validate(args.lab_smoke, False, False))
    if args.collect_manifest is not None:
        errors.extend(checker.validate_collect_manifest(args.collect_manifest))
        if args.build_manifest is not None:
            errors.extend(checker.validate_collect_build_manifest(args.collect_manifest, args.build_manifest))
        errors.extend(_validate_smoke_collect_hostname(args.lab_smoke, args.collect_manifest))
    if args.collect_root is not None and args.collect_manifest is None:
        errors.append("--collect-root requires --collect-manifest")
    if args.collect_root is not None and args.collect_manifest is not None:
        errors.extend(checker.validate_collect_root(args.collect_manifest, args.collect_root))
        errors.extend(_validate_collect_root_smoke_hostname(args.lab_smoke, args.collect_root))
    if args.collect_manifest is None:
        errors.append("evidence bundle requires --collect-manifest")
    if args.collect_root is None:
        errors.append("evidence bundle requires --collect-root")
    if args.build_manifest is None:
        errors.append("evidence bundle requires --build-manifest")
    if args.win10_upgrade_log is not None and args.win10_upgrade_status is None:
        errors.append("--win10-upgrade-log requires --win10-upgrade-status")
    if args.win10_upgrade_status is not None:
        errors.extend(checker.validate_win10_bridge_artifacts(args.win10_upgrade_status, args.win10_upgrade_log))
    if args.build_manifest is not None:
        errors.extend(_validate_build_manifest(args.build_manifest))
    if args.win10_probe is not None:
        errors.extend(_validate_win10_probe(args.win10_probe))
    smoke_payload, smoke_error = _load_json(args.lab_smoke)
    if smoke_error:
        errors.append(smoke_error)
        smoke_payload = None
    os_kind = _classify_legacy_os(smoke_payload or {})
    if os_kind == "unknown":
        errors.append("evidence bundle lab smoke is not Windows 7 SP1 or Windows Server 2008 R2 SP1")
    if os_kind == "windows_7_sp1" and (
        args.win10_upgrade_status is None or args.win10_upgrade_log is None
    ):
        errors.append("Windows 7 SP1 evidence bundle requires win10_upgrade.status.json and win10_upgrade.log")
    if os_kind == "windows_7_sp1" and args.win10_probe is None:
        errors.append("Windows 7 SP1 evidence bundle requires Windows 10 probe evidence")
    if os_kind == "server_2008_r2" and (
        args.win10_upgrade_status is not None or args.win10_upgrade_log is not None
    ):
        errors.append("Windows Server 2008 R2 SP1 evidence bundle must not include Windows 10 bridge artifacts")
    if os_kind == "server_2008_r2" and args.win10_probe is not None:
        errors.append("Windows Server 2008 R2 SP1 evidence bundle must not include Windows 10 probe evidence")
    smoke_hostname = ""
    if isinstance(smoke_payload, dict):
        raw_smoke_hostname = smoke_payload.get("hostname")
        smoke_hostname = raw_smoke_hostname.strip() if isinstance(raw_smoke_hostname, str) else ""
    if smoke_hostname and smoke_hostname.lower() != str(args.hostname or "").strip().lower():
        errors.append(f"bundle hostname {args.hostname} must match smoke hostname {smoke_hostname}")
    if errors:
        return errors

    artifacts = []
    archive_members: list[tuple[Path, str]] = []
    for role, path, archive_path in required_artifacts:
        artifacts.append(_artifact_entry(role, path, archive_path))
        archive_members.append((path, archive_path))
    for role, path, archive_path in optional_artifacts:
        if path is None:
            continue
        artifacts.append(_artifact_entry(role, path, archive_path))
        archive_members.append((path, archive_path))
    if args.collect_root is not None and args.collect_manifest is not None:
        try:
            collect_members = _collect_file_members(checker, args.collect_manifest, args.collect_root)
        except ValueError as exc:
            return str(exc).splitlines()
        collect_members.extend(
            _collect_extra_members(args.collect_root, {archive_path for _role, _path, archive_path in collect_members})
        )
        for role, path, archive_path in collect_members:
            artifacts.append(_artifact_entry(role, path, archive_path))
            archive_members.append((path, archive_path))

    docs = []
    for doc in DEFAULT_DOCS:
        archive_path = f"docs/{doc.name}" if doc.parent.name != "ship-notes" else f"docs/ship-notes/{doc.name}"
        docs.append(_artifact_entry("release_doc", doc, archive_path))
        archive_members.append((doc, archive_path))

    manifest = {
        "schema": "legacy-windows-first-iteration-evidence-bundle",
        "hostname": args.hostname,
        "os_kind": os_kind,
        "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
        "notes": args.note or [],
        "artifacts": artifacts,
        "docs": docs,
    }

    args.output.parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(args.output, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        zf.writestr("manifest.json", json.dumps(manifest, indent=2, sort_keys=True) + "\n")
        for source, archive_path in archive_members:
            zf.write(source, archive_path)
    return []


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--hostname", required=True, help="Legacy Windows endpoint hostname")
    parser.add_argument("--output", required=True, type=Path, help="Output ZIP path")
    parser.add_argument("--lab-smoke", required=True, type=Path, help="legacy-windows-lab-smoke.json")
    parser.add_argument("--collect-manifest", type=Path, help="legacy-windows-lab-collector-manifest.json")
    parser.add_argument("--collect-root", type=Path, help="Retained legacy-windows-lab-evidence-* folder to include in the ZIP")
    parser.add_argument("--build-manifest", type=Path, help="legacy-windows-first-iteration-build-manifest.json")
    parser.add_argument("--win10-upgrade-status", type=Path, help="win10_upgrade.status.json")
    parser.add_argument("--win10-upgrade-log", type=Path, help="win10_upgrade.log")
    parser.add_argument("--win10-probe", type=Path, help="Retained __WIN10_PROBE__ JSON output")
    parser.add_argument("--command-history", type=Path, help="Retained RMM command history export")
    parser.add_argument("--note", action="append", default=[], help="Operator note to include in manifest")
    args = parser.parse_args(argv)

    errors = build_bundle(args)
    if errors:
        print("legacy Windows first-iteration evidence bundle failed", file=sys.stderr)
        for error in errors:
            print(f"- {error}", file=sys.stderr)
        return 2

    print(f"legacy Windows first-iteration evidence bundle written: {args.output}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
