#!/usr/bin/env python3
"""Run source-paced LAB E8 YOLOX plus tracking without synchronous overlays."""

from __future__ import annotations

import argparse
import hashlib
import json
import math
import os
import platform
import resource
import shutil
import tempfile
import threading
import time
from collections import Counter, deque
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import numpy as np
from run_e5_instance_tracking import (
    TwoStageTracker,
    _detections,
    _duplicate_pairs,
    _infer,
    _load_valid_fov,
    _preprocess,
    _read_timeline,
    _track_document,
    _validate_source,
    _verify_model,
)
from run_e5_instance_tracking import (
    _read_profile as _read_e5_profile,
)
from run_recorded_perception_epoch import (
    _artifact,
    _canonical_json,
    _GpuTelemetry,
    _percentiles,
    _sha256,
    _valid_sha256,
    _validate_job,
    _write_json,
)

PROFILE_SCHEMA = "missioncore.e8-realtime-tracking-profile/v1"
FRAME_SCHEMA = "missioncore.e8-realtime-tracking-frame/v1"
TELEMETRY_SCHEMA = "missioncore.e8-realtime-tracking-telemetry/v1"
REPORT_SCHEMA = "missioncore.e8-realtime-tracking-report/v1"
RESULT_SCHEMA = "missioncore.e8-realtime-tracking-result/v1"
IDENTITY_SCHEMA = "missioncore.e8-realtime-tracking-identity/v1"
PIPELINE_ID = "source-paced-yolox-bytetrack-latest-wins/v1"


def _arguments() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    commands = parser.add_subparsers(dest="command", required=True)
    for name in ("preflight", "run"):
        command = commands.add_parser(name)
        command.add_argument("--job", type=Path, required=True)
        command.add_argument("--profile", type=Path, required=True)
        command.add_argument("--valid-fov-root", type=Path, required=True)
        command.add_argument("--model-root", type=Path, required=True)
        if name == "run":
            command.add_argument("--frames", type=Path, required=True)
            command.add_argument("--timeline", type=Path, required=True)
            command.add_argument("--output", type=Path, required=True)
            command.add_argument("--triton-url", required=True)
            command.add_argument("--free-bytes-floor", type=int, default=0)
            command.add_argument("--orchestrator-sha256", required=True)
            command.add_argument("--container-image", required=True)
            command.add_argument("--telemetry-interval-seconds", type=float, default=1.0)
    return parser.parse_args()


def _read_object(path: Path) -> dict[str, Any]:
    value = json.loads(path.read_text(encoding="utf-8-sig"))
    if not isinstance(value, dict):
        raise RuntimeError(f"JSON root is not an object: {path}")
    return value


def _read_profile(path: Path) -> tuple[dict[str, Any], str]:
    resolved = path.resolve(strict=True)
    profile = _read_object(resolved)
    if profile.get("schema_version") != PROFILE_SCHEMA:
        raise RuntimeError("LAB E8 profile schema changed")

    # Reuse the exhaustively tested E5 detector/tracker validation by changing
    # only the schema in memory. No E5 defaults are inferred.
    e5_shape = dict(profile)
    e5_shape["schema_version"] = "missioncore.e5-tracking-profile/v1"
    temporary: Path | None = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            prefix="lab-e8-e5-validation-",
            suffix=".json",
            delete=False,
        ) as stream:
            json.dump(e5_shape, stream, sort_keys=True, separators=(",", ":"))
            temporary = Path(stream.name)
        _read_e5_profile(temporary)
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)

    realtime = profile.get("realtime")
    acceptance = profile.get("acceptance")
    if not isinstance(realtime, dict) or not isinstance(acceptance, dict):
        raise RuntimeError("LAB E8 realtime/acceptance configuration is missing")
    mode = profile.get("mode")
    capacity = realtime.get("queue_capacity")
    speed = realtime.get("speed")
    delay = realtime.get("consumer_delay_ms")
    stale = realtime.get("stale_after_ms")
    unavailable = realtime.get("unavailable_after_ms")
    if (
        mode not in {"pilot", "qualification", "overload-negative-control"}
        or realtime.get("queue_policy") != "bounded-latest-wins"
        or not isinstance(capacity, int)
        or isinstance(capacity, bool)
        or not 1 <= capacity <= 8
        or not isinstance(speed, int | float)
        or isinstance(speed, bool)
        or not 0.1 <= float(speed) <= 10.0
        or not isinstance(delay, int | float)
        or isinstance(delay, bool)
        or not 0 <= float(delay) <= 5000
        or not isinstance(stale, int | float)
        or not isinstance(unavailable, int | float)
        or not 0 < float(stale) < float(unavailable)
    ):
        raise RuntimeError("LAB E8 scheduling configuration is invalid")
    numeric_acceptance = (
        acceptance.get("minimum_effective_fps"),
        acceptance.get("maximum_drop_fraction"),
        acceptance.get("maximum_p95_result_age_ms"),
    )
    if (
        any(
            not isinstance(value, int | float)
            or isinstance(value, bool)
            or not math.isfinite(float(value))
            or float(value) < 0
            for value in numeric_acceptance
        )
        or float(acceptance["maximum_drop_fraction"]) > 1
        or not isinstance(acceptance.get("require_zero_failures"), bool)
        or not isinstance(acceptance.get("expect_overload"), bool)
    ):
        raise RuntimeError("LAB E8 acceptance configuration is invalid")
    return profile, _sha256(resolved)


@dataclass(frozen=True, slots=True)
class FrameEnvelope:
    frame_index: int
    path: Path
    timeline: dict[str, Any]
    scheduled_monotonic: float
    decoded_monotonic: float
    image: np.ndarray
    decode_ms: float
    source_release_lag_ms: float


class LatestWinsQueue:
    def __init__(self, capacity: int) -> None:
        self.capacity = capacity
        self.items: deque[FrameEnvelope] = deque()
        self.condition = threading.Condition()
        self.maximum_depth = 0
        self.published = 0
        self.consumed = 0
        self.dropped_overflow = 0
        self.closed = False

    def publish(self, item: FrameEnvelope) -> None:
        with self.condition:
            if self.closed:
                raise RuntimeError("cannot publish to a closed LAB E8 queue")
            if len(self.items) == self.capacity:
                self.items.popleft()
                self.dropped_overflow += 1
            self.items.append(item)
            self.published += 1
            self.maximum_depth = max(self.maximum_depth, len(self.items))
            self.condition.notify()

    def take(self) -> FrameEnvelope | None:
        with self.condition:
            self.condition.wait_for(lambda: bool(self.items) or self.closed)
            if not self.items:
                return None
            value = self.items.popleft()
            self.consumed += 1
            return value

    def close(self) -> None:
        with self.condition:
            self.closed = True
            self.condition.notify_all()

    def snapshot(self) -> dict[str, int | bool]:
        with self.condition:
            return {
                "capacity": self.capacity,
                "final_depth": len(self.items),
                "maximum_depth": self.maximum_depth,
                "published": self.published,
                "consumed": self.consumed,
                "dropped_overflow": self.dropped_overflow,
                "closed": self.closed,
            }


def _wait_until(deadline: float) -> None:
    while True:
        remaining = deadline - time.perf_counter()
        if remaining <= 0:
            return
        time.sleep(min(remaining, 0.02))


def _health(age_ms: float, realtime: dict[str, Any]) -> str:
    if age_ms >= float(realtime["unavailable_after_ms"]):
        return "unavailable"
    if age_ms >= float(realtime["stale_after_ms"]):
        return "stale"
    return "healthy"


def _producer(
    *,
    queue: LatestWinsQueue,
    frame_paths: list[Path],
    timeline_rows: list[dict[str, Any]],
    replay_started: float,
    speed: float,
    error: list[BaseException],
) -> None:
    from PIL import Image

    first_session = float(timeline_rows[0]["session_seconds"])
    try:
        for frame_index, (path, row) in enumerate(zip(frame_paths, timeline_rows, strict=True)):
            scheduled = replay_started + (float(row["session_seconds"]) - first_session) / speed
            _wait_until(scheduled)
            decode_started = time.perf_counter()
            with Image.open(path) as opened:
                image = np.asarray(opened.convert("RGB"), dtype=np.uint8)
            decoded = time.perf_counter()
            if image.shape != (600, 800, 3):
                raise RuntimeError("LAB E8 input resolution changed")
            queue.publish(
                FrameEnvelope(
                    frame_index=frame_index,
                    path=path,
                    timeline=row,
                    scheduled_monotonic=scheduled,
                    decoded_monotonic=decoded,
                    image=image,
                    decode_ms=(decoded - decode_started) * 1000.0,
                    source_release_lag_ms=max(0.0, (decode_started - scheduled) * 1000.0),
                )
            )
    except BaseException as exc:
        error.append(exc)
    finally:
        queue.close()


def _assert_disk_floor(path: Path, floor: int, frame: int) -> None:
    if floor < 0:
        raise RuntimeError("LAB E8 disk floor is invalid")
    free = shutil.disk_usage(path).free
    if floor and free < floor:
        raise RuntimeError(f"LAB E8 crossed the D-backed free-space floor at frame {frame}")


def _preflight(args: argparse.Namespace) -> int:
    job = _validate_job(args.job.resolve(strict=True))
    profile, profile_sha256 = _read_profile(args.profile)
    _validate_source(job, profile)
    valid_mask, valid_fov = _load_valid_fov(args.valid_fov_root, job, profile)
    model_files = _verify_model(profile, args.model_root)
    from scipy.optimize import linear_sum_assignment  # noqa: F401

    print(
        json.dumps(
            {
                "state": "preflight-ready",
                "job_id": job["job_id"],
                "profile_sha256": profile_sha256,
                "model_files": model_files,
                "valid_fov": valid_fov,
                "valid_pixels": int(valid_mask.sum()),
            },
            sort_keys=True,
        ),
        flush=True,
    )
    return 0


def _run(args: argparse.Namespace) -> int:
    from PIL import Image
    from scipy.optimize import linear_sum_assignment

    if not _valid_sha256(args.orchestrator_sha256):
        raise RuntimeError("LAB E8 orchestrator SHA-256 is invalid")
    if not args.triton_url.startswith("http://") or len(args.triton_url) > 256:
        raise RuntimeError("LAB E8 Triton URL is invalid")
    job = _validate_job(args.job.resolve(strict=True))
    profile, profile_sha256 = _read_profile(args.profile)
    _validate_source(job, profile)
    valid_mask, valid_fov = _load_valid_fov(args.valid_fov_root, job, profile)
    model_files = _verify_model(profile, args.model_root)
    frames_root = args.frames.resolve(strict=True)
    frame_paths = sorted(frames_root.glob("frame-*.png"))
    if not frame_paths or [path.name for path in frame_paths] != [
        f"frame-{index:06d}.png" for index in range(1, len(frame_paths) + 1)
    ]:
        raise RuntimeError("LAB E8 decoded frame sequence changed")
    timeline_path = args.timeline.resolve(strict=True)
    timeline_rows = _read_timeline(timeline_path, len(frame_paths))
    input_timeline = job["input"]["timeline"]
    if float(timeline_rows[0]["session_seconds"]) < float(input_timeline["start_seconds"]) or float(
        timeline_rows[-1]["session_seconds"]
    ) > float(input_timeline["end_seconds"]):
        raise RuntimeError("LAB E8 clip escaped the source timeline")

    output = args.output.resolve()
    if output.exists():
        raise RuntimeError("LAB E8 output must be absent")
    output.mkdir(mode=0o700, parents=True, exist_ok=False)
    _assert_disk_floor(output, args.free_bytes_floor, 0)

    tracker = TwoStageTracker(profile["tracking"])
    linear_sum_assignment(np.zeros((1, 1), dtype=np.float64))
    with Image.open(frame_paths[0]) as opened:
        warm_image = np.asarray(opened.convert("RGB"), dtype=np.uint8)
    warm_tensor = _preprocess(warm_image, valid_mask, profile)
    _infer(args.triton_url, profile["model"], warm_tensor)
    del warm_tensor, warm_image

    realtime = profile["realtime"]
    queue = LatestWinsQueue(int(realtime["queue_capacity"]))
    producer_errors: list[BaseException] = []
    latency = {
        name: []
        for name in (
            "image_decode_ms",
            "source_release_lag_ms",
            "queue_wait_ms",
            "preprocess_ms",
            "triton_request_ms",
            "postprocess_ms",
            "tracking_ms",
            "processing_ms",
            "result_age_ms",
        )
    }
    detection_labels: Counter[str] = Counter()
    track_labels: Counter[str] = Counter()
    rejection_reasons: Counter[str] = Counter()
    health_counts: Counter[str] = Counter()
    unique_track_ids: set[int] = set()
    duplicate_pairs = 0
    failures = 0
    metadata_path = output / "frames.jsonl"
    telemetry_path = output / "telemetry.jsonl"
    gpu_path = output / "gpu-telemetry.jsonl"
    disk_before = shutil.disk_usage(output).free
    replay_started = time.perf_counter() + 0.25
    producer = threading.Thread(
        target=_producer,
        kwargs={
            "queue": queue,
            "frame_paths": frame_paths,
            "timeline_rows": timeline_rows,
            "replay_started": replay_started,
            "speed": float(realtime["speed"]),
            "error": producer_errors,
        },
        name="lab-e8-source-producer",
        daemon=True,
    )
    producer.start()

    with (
        metadata_path.open("x", encoding="utf-8", newline="\n") as metadata_stream,
        telemetry_path.open("x", encoding="utf-8", newline="\n") as telemetry_stream,
        gpu_path.open("x", encoding="utf-8", newline="\n") as gpu_stream,
        _GpuTelemetry(gpu_stream, args.telemetry_interval_seconds) as gpu_telemetry,
    ):
        while (envelope := queue.take()) is not None:
            processing_started = time.perf_counter()
            latency["image_decode_ms"].append(envelope.decode_ms)
            latency["source_release_lag_ms"].append(envelope.source_release_lag_ms)
            latency["queue_wait_ms"].append(
                max(0.0, (processing_started - envelope.decoded_monotonic) * 1000.0)
            )
            try:
                preprocess_started = time.perf_counter()
                tensor = _preprocess(envelope.image, valid_mask, profile)
                latency["preprocess_ms"].append((time.perf_counter() - preprocess_started) * 1000.0)
                output_tensor, request_ms = _infer(args.triton_url, profile["model"], tensor)
                latency["triton_request_ms"].append(request_ms)

                postprocess_started = time.perf_counter()
                detections, rejected = _detections(output_tensor, profile, valid_mask)
                latency["postprocess_ms"].append(
                    (time.perf_counter() - postprocess_started) * 1000.0
                )
                rejection_reasons.update(rejected)
                detection_labels.update(str(item["label"]) for item in detections)

                tracking_started = time.perf_counter()
                tracks = tracker.update(detections, envelope.frame_index)
                latency["tracking_ms"].append((time.perf_counter() - tracking_started) * 1000.0)
                duplicate_pairs += _duplicate_pairs(tracks)
                for track in tracks:
                    unique_track_ids.add(track.track_id)
                    track_labels[track.label] += 1
                delay_seconds = float(realtime["consumer_delay_ms"]) / 1000.0
                if delay_seconds:
                    time.sleep(delay_seconds)
            except Exception:
                failures += 1
                raise

            published = time.perf_counter()
            processing_ms = (published - processing_started) * 1000.0
            result_age_ms = max(0.0, (published - envelope.scheduled_monotonic) * 1000.0)
            latency["processing_ms"].append(processing_ms)
            latency["result_age_ms"].append(result_age_ms)
            health = _health(result_age_ms, realtime)
            health_counts[health] += 1
            frame_document = {
                "schema_version": FRAME_SCHEMA,
                "frame_index": envelope.frame_index,
                "sequence": envelope.frame_index + 1,
                "source_frame_index": envelope.timeline["source_frame_index"],
                "source_sequence": envelope.timeline["source_sequence"],
                "session_seconds": round(float(envelope.timeline["session_seconds"]), 9),
                "detections": detections,
                "tracks": [_track_document(track) for track in tracks],
                "delivery": {"health": health, "result_age_ms": round(result_age_ms, 6)},
            }
            metadata_stream.write(
                json.dumps(
                    frame_document,
                    ensure_ascii=False,
                    sort_keys=True,
                    separators=(",", ":"),
                    allow_nan=False,
                )
                + "\n"
            )
            queue_state = queue.snapshot()
            telemetry_stream.write(
                json.dumps(
                    {
                        "schema_version": TELEMETRY_SCHEMA,
                        "frame_index": envelope.frame_index,
                        "session_seconds": frame_document["session_seconds"],
                        "health": health,
                        "result_age_ms": round(result_age_ms, 6),
                        "processing_ms": round(processing_ms, 6),
                        "queue_depth_after_take": queue_state["final_depth"],
                        "queue_dropped_overflow": queue_state["dropped_overflow"],
                    },
                    sort_keys=True,
                    separators=(",", ":"),
                )
                + "\n"
            )
            completed = int(queue_state["consumed"])
            if completed % 100 == 0:
                metadata_stream.flush()
                telemetry_stream.flush()
                gpu_stream.flush()
                _assert_disk_floor(output, args.free_bytes_floor, completed)
                print(
                    json.dumps(
                        {
                            "phase": "realtime-tracking",
                            "frames_consumed": completed,
                            "frames_published": queue_state["published"],
                            "frames_dropped": queue_state["dropped_overflow"],
                            "maximum_queue_depth": queue_state["maximum_depth"],
                        },
                        sort_keys=True,
                    ),
                    flush=True,
                )
        producer.join(timeout=5)
        if producer.is_alive():
            raise RuntimeError("LAB E8 producer did not terminate")
        if producer_errors:
            raise RuntimeError("LAB E8 producer failed") from producer_errors[0]
        metadata_stream.flush()
        telemetry_stream.flush()
        os.fsync(metadata_stream.fileno())
        os.fsync(telemetry_stream.fileno())

    finished = time.perf_counter()
    queue_state = queue.snapshot()
    source_span = (
        float(timeline_rows[-1]["session_seconds"]) - float(timeline_rows[0]["session_seconds"])
    ) / float(realtime["speed"])
    wall_seconds = finished - replay_started
    effective_fps = int(queue_state["consumed"]) / max(source_span, wall_seconds, 1e-9)
    drop_fraction = int(queue_state["dropped_overflow"]) / len(frame_paths)
    result_age = _percentiles(latency["result_age_ms"])
    acceptance = profile["acceptance"]
    expect_overload = bool(acceptance["expect_overload"])
    checks = {
        "queue_bounded": int(queue_state["maximum_depth"]) <= int(queue_state["capacity"]),
        "producer_accounting": int(queue_state["published"]) == len(frame_paths),
        "consumer_accounting": (
            int(queue_state["consumed"]) + int(queue_state["dropped_overflow"]) == len(frame_paths)
        ),
        "zero_failures": failures == 0,
    }
    if expect_overload:
        checks["overload_drop_observed"] = int(queue_state["dropped_overflow"]) > 0
        checks["overload_degraded_or_stale_observed"] = (
            health_counts["stale"] + health_counts["unavailable"] > 0
        )
    else:
        checks.update(
            {
                "minimum_effective_fps": effective_fps
                >= float(acceptance["minimum_effective_fps"]),
                "maximum_drop_fraction": drop_fraction
                <= float(acceptance["maximum_drop_fraction"]),
                "maximum_p95_result_age_ms": float(result_age["p95"])
                <= float(acceptance["maximum_p95_result_age_ms"]),
            }
        )
    accepted = all(checks.values())

    identity = {
        "schema_version": IDENTITY_SCHEMA,
        "job_id": job["job_id"],
        "input_sha256": job["input_sha256"],
        "session_id": job["input"]["session_id"],
        "source_id": job["input"]["source_id"],
        "selection": {
            "frame_count": len(frame_paths),
            "source_start_frame_index": timeline_rows[0]["source_frame_index"],
            "source_end_frame_index": timeline_rows[-1]["source_frame_index"],
            "timeline_start_seconds": timeline_rows[0]["session_seconds"],
            "timeline_end_seconds": timeline_rows[-1]["session_seconds"],
            "timeline_sha256": _sha256(timeline_path),
        },
        "configuration": {
            "pipeline": PIPELINE_ID,
            "profile_sha256": profile_sha256,
            "profile": profile,
            "runner_sha256": _sha256(Path(__file__).resolve(strict=True)),
            "orchestrator_sha256": args.orchestrator_sha256,
            "container_image": args.container_image,
            "valid_fov": valid_fov,
        },
        "models": {"detector": profile["model"], "files": model_files},
    }
    identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
    result_id = f"e8-realtime-tracking-{identity_sha256}"
    metrics = {
        "frames_expected": len(frame_paths),
        "frames_published": queue_state["published"],
        "frames_processed": queue_state["consumed"],
        "frames_dropped": queue_state["dropped_overflow"],
        "frames_failed": failures,
        "drop_fraction": round(drop_fraction, 9),
        "source_span_seconds": round(source_span, 6),
        "replay_wall_seconds": round(wall_seconds, 6),
        "effective_frames_per_second": round(effective_fps, 6),
        "queue": queue_state,
        "latency_ms": {name: _percentiles(values) for name, values in latency.items()},
        "health_counts": dict(sorted(health_counts.items())),
        "detections": int(sum(detection_labels.values())),
        "detections_by_label": dict(sorted(detection_labels.items())),
        "detection_rejections": dict(sorted(rejection_reasons.items())),
        "unique_confirmed_tracks": len(unique_track_ids),
        "track_observations": int(sum(track_labels.values())),
        "track_observations_by_label": dict(sorted(track_labels.items())),
        "same_class_duplicate_pairs_iou_ge_0_8": duplicate_pairs,
        "tracker_tracks_created": tracker.created,
        "tracker_tracks_retired": tracker.retired,
        "process_peak_rss_mib": round(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024, 3),
        "gpu_telemetry": gpu_telemetry.summary(),
        "disk": {
            "free_bytes_before_replay": disk_before,
            "free_bytes_after_replay": shutil.disk_usage(output).free,
            "free_bytes_floor": args.free_bytes_floor,
        },
    }
    report = {
        "schema_version": REPORT_SCHEMA,
        "result_id": result_id,
        "created_at_utc": datetime.now(UTC)
        .isoformat(timespec="milliseconds")
        .replace("+00:00", "Z"),
        "state": "accepted" if accepted else "rejected",
        "ground_truth": False,
        "identity": identity,
        "runtime": {
            "hostname": platform.node(),
            "platform": platform.platform(),
            "python": platform.python_version(),
            "numpy": np.__version__,
        },
        "metrics": metrics,
        "acceptance": {
            "accepted": accepted,
            "checks": checks,
            "navigation_or_safety_accepted": False,
        },
        "limitations": [
            "Recorded source-paced replay, not a live K1 transport.",
            "Generic COCO detector and IoU-only tracking are not safety validated.",
            "No semantic model, LiDAR fusion or navigation actuation is executed in this gate.",
            (
                "Decoded PNG input is temporary lab transport; camera ingest decode "
                "remains a separate gate."
            ),
        ],
    }
    report_path = output / "run-report.json"
    _write_json(report_path, report)
    artifacts = [
        _artifact(metadata_path, "realtime-tracking-frames", "application/x-ndjson", FRAME_SCHEMA),
        _artifact(
            telemetry_path, "realtime-tracking-telemetry", "application/x-ndjson", TELEMETRY_SCHEMA
        ),
        _artifact(gpu_path, "worker-gpu-telemetry", "application/x-ndjson"),
        _artifact(report_path, "realtime-tracking-report", "application/json", REPORT_SCHEMA),
    ]
    result = {
        "schema_version": RESULT_SCHEMA,
        "result_id": result_id,
        "identity_sha256": identity_sha256,
        "identity": identity,
        "created_at_utc": report["created_at_utc"],
        "acceptance_state": report["state"],
        "ground_truth": False,
        "publication_scope": "recorded-realtime-qualification-only",
        "frames_processed": queue_state["consumed"],
        "artifacts": artifacts,
    }
    _write_json(output / "result.json", result)
    print(
        json.dumps(
            {
                "result_id": result_id,
                "accepted": accepted,
                "frames_processed": queue_state["consumed"],
                "frames_dropped": queue_state["dropped_overflow"],
                "effective_fps": round(effective_fps, 6),
                "result_age_p95_ms": result_age["p95"],
            },
            sort_keys=True,
        ),
        flush=True,
    )
    return 0 if accepted else 2


def main() -> int:
    args = _arguments()
    if args.command == "preflight":
        return _preflight(args)
    return _run(args)


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