#!/usr/bin/env python3
"""Unified build script for demo GIFs.

Usage:
    ./build docs                    # Build doc site demos (light + dark, 1600x900)
    ./build social                  # Build social media demos (light only, 1200x700)
    ./build docs --only wt-merge    # Build specific demo for docs
    ./build social --only wt-zellij-omnibus --shell  # Debug shell for social demo

Output is written to docs/public/assets/{mode}/{theme}/, ensuring docs and social
builds don't overwrite each other. The assets repo mirrors this structure.
"""

from __future__ import annotations

import argparse
import os
import subprocess
import sys
import tempfile
import time
from contextlib import contextmanager
from concurrent.futures import ProcessPoolExecutor, as_completed
from functools import partial
from pathlib import Path

# Add docs/demos/ to path for shared library
SCRIPT_DIR = Path(__file__).parent
sys.path.insert(0, str(SCRIPT_DIR))

from shared import (  # noqa: E402
    VALIDATION_RS,
    DemoEnv,
    DemoSize,
    git,
    prepare_demo_repo,
    claude_auth_available,
    setup_claude_code_config,
    setup_zellij_config,
    setup_fish_config,
    check_dependencies,
    check_ffmpeg_libass,
    setup_demo_output,
    write_starship_config,
    record_theme,
    recorder_env,
    SIZE_DOCS,
    SIZE_DOCS_MOBILE,
    SIZE_SOCIAL,
    record_text,
    record_snapshot,
    build_tape_replacements,
    ensure_vhs_binary,
)
from shared.validation import (  # noqa: E402
    TUI_CHECKPOINTS,
    validate_tui_demo_verbose,
)

REPO_ROOT = SCRIPT_DIR.parent.parent
OUT_DIR = SCRIPT_DIR.parent / "public" / "assets"
TAPES_DIR = SCRIPT_DIR / "tapes"
SNAPSHOTS_DIR = SCRIPT_DIR / "snapshots"

# Demos that use TUI or interactive input - not suitable for command snapshots
TUI_DEMOS = {"wt-switch-picker", "wt-switch", "wt-statusline", "wt-zellij-omnibus"}
CLAUDE_DEMOS = {"wt-switch", "wt-statusline", "wt-zellij-omnibus"}


# =============================================================================
# Setup Helpers
# =============================================================================


@contextmanager
def prepared_demo_env(output_name: str, theme: str, setup_func):
    """Create a prepared demo environment and remove it after use."""
    with tempfile.TemporaryDirectory(
        prefix="wt-demo-", ignore_cleanup_errors=True
    ) as demo_env_dir:
        yield _prepare_demo_env(Path(demo_env_dir), output_name, theme, setup_func)


def _prepare_demo_env(
    out_dir: Path, output_name: str, theme: str, setup_func
) -> DemoEnv:
    """Prepare a demo environment at an existing directory."""
    env = DemoEnv(name=output_name, out_dir=out_dir, theme=theme, repo_name="acme")
    setup_demo_output(env)
    setup_func(env)
    return env


def _add_billing_branch(env: DemoEnv):
    """Add a billing branch at same commit as main (merged state)."""
    branch = "billing"
    path = env.work_base / f"acme.{branch}"
    git(["-C", str(env.repo), "worktree", "add", "-q", "-b", branch, str(path), "main"])
    git(["-C", str(path), "push", "-u", "origin", branch, "-q"])


def _add_staged_validation(env: DemoEnv):
    """Add validation.rs with staged changes to hooks branch."""
    hooks_path = env.work_base / "acme.hooks"
    if hooks_path.exists():
        new_file = hooks_path / "src" / "validation.rs"
        new_file.write_text(VALIDATION_RS)
        git(["-C", str(hooks_path), "add", "src/validation.rs"])


def _write_user_config(
    env: DemoEnv, approved_commands: list[str], commit_generation: bool = True
):
    """Write worktrunk user config and approvals."""
    config_dir = env.home / ".config" / "worktrunk"
    project_id = str(env.bare_remote).removesuffix(".git")

    # User config (portable settings only)
    config_content = ""
    if commit_generation:
        config_content += """[commit.generation]
command = "llm -m claude-haiku-4.5"

"""
    config_content += """[list]
summary = true

"""
    (config_dir / "config.toml").write_text(config_content)

    # Approvals (machine-local trust state, separate from config)
    approvals_content = f'[projects."{project_id}"]\n'
    approvals_content += f"approved-commands = {approved_commands!r}\n"
    (config_dir / "approvals.toml").write_text(approvals_content)


# =============================================================================
# Demo Setup Functions
# =============================================================================


def prepare_basic(env: DemoEnv):
    """Basic repo setup - no hooks, no extras."""
    prepare_demo_repo(env, REPO_ROOT)


def prepare_with_hooks(env: DemoEnv, include_deps: bool = False):
    """Standard hooks demo: dev server + cleanup + billing branch."""
    dev_cmd = "npm run dev -- --port {{ branch | hash_port }}"
    cleanup_cmd = "flyctl scale count 0"

    post_start = ["[post-create]"]
    approved = []
    if include_deps:
        post_start.append('deps = "npm install"')
        approved.append("npm install")
    post_start.append(f'dev = "{dev_cmd}"')
    approved.append(dev_cmd)
    approved.append(cleanup_cmd)

    hooks_config = (
        "\n".join(post_start) + f'\n\n[pre-remove]\ncleanup = "{cleanup_cmd}"\n'
    )

    prepare_demo_repo(env, REPO_ROOT, hooks_config=hooks_config)
    _write_user_config(env, approved)
    _add_billing_branch(env)


def prepare_merge_demo(
    env: DemoEnv, commit_generation: bool = True, staged_changes: bool = True
):
    """Merge demo setup. Use commit_generation=False for social (no LLM in GIF)."""
    prepare_demo_repo(env, REPO_ROOT)
    _write_user_config(env, ["cargo nextest run"], commit_generation=commit_generation)
    if staged_changes:
        _add_staged_validation(env)


def prepare_switch(env: DemoEnv):
    """Switch demo with Claude Code UI."""
    dev_cmd = "npm run dev -- --port {{ branch | hash_port }}"
    hooks_config = f"""[post-create]
copy = "wt step copy-ignored"
dev = "{dev_cmd}"
"""
    prepare_demo_repo(env, REPO_ROOT, hooks_config=hooks_config)
    _write_user_config(env, [dev_cmd, "wt step copy-ignored"])

    # Create .env file (gitignored) so copy-ignored has something to copy
    gitignore = env.repo / ".gitignore"
    gitignore.write_text(gitignore.read_text() + ".env\n")
    env_file = env.repo / ".env"
    env_file.write_text("DATABASE_URL=postgres://localhost/acme_dev\nAPI_KEY=dev-key-123\n")

    setup_claude_code_config(
        env,
        [
            str(env.repo),
            str(env.work_base / "acme.alpha"),
            str(env.work_base / "acme.api"),
            str(env.work_base / "acme.dashboard"),
        ],
    )


def prepare_devserver(env: DemoEnv):
    """Setup for wt-devserver demo - shows URL column in wt list."""
    dev_cmd = "npm run dev -- --port {{ branch | hash_port }}"
    hooks_config = f'''[post-create]
dev = "{dev_cmd}"

[list]
url = "http://localhost:{{{{ branch | hash_port }}}}"
'''
    prepare_demo_repo(env, REPO_ROOT, hooks_config=hooks_config)
    _write_user_config(env, [dev_cmd])

    # Override npm mock to start nc listener so URL appears active
    npm_mock = env.home / ".local" / "bin" / "npm"
    npm_mock.write_text("""#!/bin/bash
if [[ "$1" == "run" && "$2" == "dev" ]]; then
    PORT="$5"
    echo ""
    echo "  VITE v5.4.2  ready in 342 ms"
    echo ""
    echo "  ➜  Local:   http://localhost:$PORT/"
    # Start nc listener so wt list shows URL as active (not dimmed)
    nc -l "$PORT" >/dev/null 2>&1 &
fi
""")
    npm_mock.chmod(0o755)


def prepare_zellij_omnibus(env: DemoEnv):
    """Setup for wt-zellij-omnibus demo - comprehensive showcase."""
    hooks_config = """[post-create]
dev = "npm run dev -- --port {{ branch | hash_port }}"

[pre-merge]
test = "cargo nextest run"

[list]
url = "http://localhost:{{ branch | hash_port }}"
"""
    prepare_demo_repo(env, REPO_ROOT, hooks_config=hooks_config)

    # Clean up branches that might exist from previous runs (billing, feature created during demo)
    origin = env.home / "origin.git"
    for branch in ["billing", "feature"]:
        # Delete from origin if exists (ignore errors if not)
        subprocess.run(
            ["git", "-C", str(origin), "branch", "-D", branch], capture_output=True
        )

    # Enable git colors for non-TTY environments (VHS)
    git(["-C", str(env.repo), "config", "color.ui", "always"])

    _write_user_config(
        env,
        ["npm run dev -- --port {{ branch | hash_port }}", "cargo nextest run"],
    )

    # Zellij + Fish configs (with --create in wsl abbreviation)
    setup_zellij_config(env, default_cwd=str(env.repo))
    setup_fish_config(env, wsl_create=True)

    # Claude Code config with pre-approved tools
    worktree_branches = ["api", "auth", "billing", "feature"]
    worktree_paths = [str(env.repo)] + [
        str(env.work_base / f"acme.{branch}") for branch in worktree_branches
    ]
    allowed_tools = [
        "Bash",
        "Read",
        "Write",
        "Edit",
        "Glob",
        "Grep",
        "Task",
        "TodoWrite",
    ]
    setup_claude_code_config(env, worktree_paths, allowed_tools)

    # Pre-create worktrees (api and auth only - billing created during demo)
    demo_env = {
        **os.environ,
        "HOME": str(env.home),
        "PATH": f"{REPO_ROOT}/target/debug:{env.home}/.local/bin:{os.environ.get('PATH', '')}",
    }

    def run_wt(args, cwd):
        result = subprocess.run(
            ["wt"] + args, cwd=str(cwd), env=demo_env, capture_output=True, text=True
        )
        if result.returncode != 0:
            raise RuntimeError(f"wt {' '.join(args)} failed: {result.stderr}")

    run_wt(["switch", "--create", "api"], env.repo)
    git(["-C", str(env.work_base / "acme.api"), "push", "-u", "origin", "api", "-q"])

    run_wt(["switch", "--create", "auth"], env.repo)
    git(["-C", str(env.work_base / "acme.auth"), "push", "-u", "origin", "auth", "-q"])

    # Return to main worktree for the demo to start
    run_wt(["switch", "main"], env.work_base / "acme.api")


# =============================================================================
# Demo Registrations
# =============================================================================

# Format: (tape_file, output_name, setup_func)
# Demos use their mode's default size unless TARGETS provides a named override.
# Note: setup_func must be a named function (not lambda) for multiprocessing.

DOCS_DEMOS = [
    ("wt-core.tape", "wt-core", prepare_with_hooks),
    ("wt-core-mobile.tape", "wt-core-mobile", prepare_with_hooks),
    ("wt-switch.tape", "wt-switch", prepare_switch),
    ("wt-list.tape", "wt-list", prepare_with_hooks),
    ("wt-commit.tape", "wt-commit", prepare_merge_demo),
    ("wt-statusline.tape", "wt-statusline", prepare_switch),
    ("wt-merge.tape", "wt-merge", prepare_merge_demo),
    ("wt-switch-picker.tape", "wt-switch-picker", prepare_basic),
    ("wt-zellij-omnibus.tape", "wt-zellij-omnibus", prepare_zellij_omnibus),
]

SOCIAL_DEMOS = [
    ("wt-switch.tape", "wt-switch", prepare_switch),
    ("wt-statusline.tape", "wt-statusline", prepare_switch),
    ("wt-list.tape", "wt-list", prepare_with_hooks),
    ("wt-list-remove.tape", "wt-list-remove", prepare_with_hooks),
    ("wt-hooks.tape", "wt-hooks", partial(prepare_with_hooks, include_deps=True)),
    ("wt-devserver.tape", "wt-devserver", prepare_devserver),
    ("wt-commit.tape", "wt-commit", prepare_merge_demo),
    (
        "wt-merge.tape",
        "wt-merge",
        partial(prepare_merge_demo, commit_generation=False, staged_changes=False),
    ),
    ("wt-switch-picker.tape", "wt-switch-picker", prepare_basic),
    ("wt-core.tape", "wt-core", prepare_with_hooks),
    ("wt-zellij-omnibus.tape", "wt-zellij-omnibus", prepare_zellij_omnibus),
]

TARGETS = {
    "docs": {
        "demos": DOCS_DEMOS,
        "size": SIZE_DOCS,
        "sizes": {"wt-core-mobile": SIZE_DOCS_MOBILE},
        "themes": ["light", "dark"],
    },
    "social": {
        "demos": SOCIAL_DEMOS,
        "size": SIZE_SOCIAL,
        "themes": ["light"],
    },
}


# =============================================================================
# Parallel Recording
# =============================================================================


def record_demo(
    tape_file: str,
    output_name: str,
    setup_func,
    mode_out_dir: Path,
    themes: list[str],
    demo_size: DemoSize,
    vhs_binary: str,
    text_mode: bool = False,
    snapshot_mode: bool = False,
) -> float:
    """Record a single demo. Returns elapsed seconds."""
    t0 = time.monotonic()
    tape_path = TAPES_DIR / tape_file
    if not tape_path.exists():
        raise FileNotFoundError(f"{tape_file} not found")

    # Snapshot and text output carry no colors, so they use the first theme.
    if snapshot_mode:
        with prepared_demo_env(output_name, themes[0], setup_func) as env:
            output_snap = SNAPSHOTS_DIR / f"{output_name}.snap"
            record_snapshot(env, tape_path, output_snap, REPO_ROOT)
        elapsed = time.monotonic() - t0
        print(f"✓ {output_name}: Snapshot saved ({elapsed:.0f}s)")
        return elapsed

    if text_mode:
        with prepared_demo_env(output_name, themes[0], setup_func) as env:
            replacements = build_tape_replacements(env, REPO_ROOT)
            output_txt = mode_out_dir / f"{output_name}.txt"
            record_text(env, tape_path, output_txt, replacements, REPO_ROOT)
        elapsed = time.monotonic() - t0
        print(f"✓ {output_name}: Text saved ({elapsed:.0f}s)")
        return elapsed

    output_gifs = {}
    for theme in themes:
        theme_dir = mode_out_dir / theme
        theme_dir.mkdir(parents=True, exist_ok=True)
        output_gifs[theme] = theme_dir / f"{output_name}.gif"

    for theme, output_gif in output_gifs.items():
        # Every tape mutates its repository. A fresh environment keeps one
        # theme's commands from changing the starting state of the next theme.
        with prepared_demo_env(output_name, theme, setup_func) as env:
            record_theme(
                env,
                tape_path,
                output_gif,
                REPO_ROOT,
                vhs_binary=vhs_binary,
                size=demo_size,
            )

    elapsed = time.monotonic() - t0

    # Validate TUI demos with OCR after recording
    if output_name in TUI_CHECKPOINTS:
        validation_failures = {}
        for theme, gif_path in output_gifs.items():
            success, output = validate_tui_demo_verbose(output_name, gif_path)
            if not success:
                validation_failures[theme] = output

        if validation_failures:
            print(f"✓ {output_name}: GIFs saved ({elapsed:.0f}s)")
            for theme, output in validation_failures.items():
                print(f"✗ {output_name} ({theme}): Validation failed")
                for line in output.split("\n"):
                    if line.startswith("  "):
                        print(line)
            raise RuntimeError(f"TUI validation failed for {output_name}")
        print(
            f"✓ {output_name}: GIFs saved + validation passed: "
            f"{', '.join(themes)} ({elapsed:.0f}s)"
        )
    else:
        print(f"✓ {output_name}: GIFs saved: {', '.join(themes)} ({elapsed:.0f}s)")

    return elapsed


# =============================================================================
# Main
# =============================================================================


def spawn_debug_shell(env: DemoEnv):
    """Spawn an interactive fish shell with the demo environment set up."""
    shell_env = recorder_env()
    shell_env.update(
        {
            "HOME": str(env.home),
            "XDG_CONFIG_HOME": str(env.home / ".config"),
            "STARSHIP_CONFIG": str(env.starship_config),
            "PATH": f"{REPO_ROOT / 'target' / 'debug'}:{env.home / '.local' / 'bin'}:{os.environ.get('PATH', '')}",
            # Keep real rustup/cargo for any rust tools
            "RUSTUP_HOME": str(Path.home() / ".rustup"),
            "CARGO_HOME": str(Path.home() / ".cargo"),
        }
    )

    print("\nSpawning fish shell in demo environment...")
    print(f"  HOME={env.home}")
    print(f"  Demo repo: {env.repo}\n")

    init_cmd = "starship init fish | source; wt config shell init fish | source"
    subprocess.run(["fish", "-C", init_cmd], env=shell_env, cwd=str(env.repo))


def main():
    parser = argparse.ArgumentParser(
        description="Build demo GIFs for worktrunk",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  ./build docs social             Build all demos for both targets
  ./build docs                    Build all doc site demos (light + dark)
  ./build social                  Build all social media demos (light only)
  ./build docs --only wt-merge    Build specific demo for docs
  ./build docs --text             Build text output instead of GIFs
  ./build social --only wt-zellij-omnibus --shell   Debug shell for demo
""",
    )
    parser.add_argument(
        "target",
        nargs="+",
        choices=["docs", "social"],
        help="Target(s) to build demos for",
    )
    parser.add_argument("--only", help="Only record specific demo (e.g., wt-switch)")
    parser.add_argument(
        "--shell",
        action="store_true",
        help="Build demo and spawn interactive shell for debugging (requires --only)",
    )
    parser.add_argument(
        "--text", action="store_true", help="Record text output instead of GIFs"
    )
    parser.add_argument(
        "--snapshot",
        action="store_true",
        help="Record command output snapshots for regression testing (non-TUI demos only)",
    )
    parser.add_argument(
        "--sequential",
        action="store_true",
        help="Record demos sequentially instead of in parallel",
    )
    args = parser.parse_args()

    if args.shell and not args.only:
        print("--shell requires --only to specify which demo to debug")
        return

    if args.shell and args.text:
        print("--shell and --text are mutually exclusive")
        return

    if args.shell and args.snapshot:
        print("--shell and --snapshot are mutually exclusive")
        return

    if args.text and args.snapshot:
        print("--text and --snapshot are mutually exclusive")
        return

    if args.only and len(args.target) > 1:
        print("--only cannot be used with multiple targets")
        return

    selected_claude_demos = sorted(
        {
            name
            for target in args.target
            for _, name, _ in TARGETS[target]["demos"]
            if name in CLAUDE_DEMOS and (args.only is None or name == args.only)
        }
    )
    if (
        selected_claude_demos
        and not args.shell
        and not args.snapshot
        and not claude_auth_available()
    ):
        print(
            "✗ Claude Code authentication is required to record: "
            f"{', '.join(selected_claude_demos)}",
            file=sys.stderr,
        )
        if sys.platform == "darwin":
            print(
                "↳ Run claude auth login, or set CLAUDE_CODE_OAUTH_TOKEN "
                "or ANTHROPIC_API_KEY",
                file=sys.stderr,
            )
        else:
            print(
                "↳ Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY",
                file=sys.stderr,
            )
        sys.exit(1)

    # Check dependencies (VHS is built from source, not required in PATH)
    deps = ["wt", "starship", "zellij"]
    check_dependencies(deps)
    if not args.text and not args.snapshot:
        check_dependencies(["delta"])
        check_ffmpeg_libass()

    # Kill stale Zellij processes from previous demo runs (they interfere
    # with new recordings by holding locks or sockets)
    subprocess.run(["pkill", "-f", "zellij.*wt-demos"], capture_output=True)

    # Ensure VHS is built (requires Go)
    vhs_binary = str(ensure_vhs_binary())

    # Build each target sequentially (parallel targets cause Zellij conflicts)
    t0 = time.monotonic()
    all_failed = []
    for target in dict.fromkeys(args.target):  # deduplicate, preserve order
        failed = build_target(
            target, args, vhs_binary,
        )
        all_failed.extend(f"{target}/{name}" for name in failed)

    # Overall summary for multi-target builds
    if len(args.target) > 1:
        elapsed = time.monotonic() - t0
        mins, secs = divmod(int(elapsed), 60)
        print(f"\n{'=' * 60}")
        print(f"Total: {mins}m{secs:02d}s")

    if all_failed:
        if len(args.target) <= 1:
            print(f"\n{'=' * 60}")
        print(f"Failed: {', '.join(all_failed)}")
        sys.exit(1)


def build_target(target: str, args, vhs_binary: str) -> list[str]:
    """Build all demos for a single target. Returns list of failed demo names."""
    t0 = time.monotonic()
    mode_out_dir = OUT_DIR / target
    mode_out_dir.mkdir(parents=True, exist_ok=True)

    if target == "docs":
        write_starship_config(mode_out_dir / "starship.toml", "light")

    target_config = TARGETS[target]
    demos = target_config["demos"]
    demo_size = target_config["size"]
    demo_sizes = target_config.get("sizes", {})
    themes = target_config["themes"]

    # Filter demos if --only specified
    if args.only:
        demos = [(t, n, s) for t, n, s in demos if n == args.only]
        if not demos:
            all_names = [n for _, n, _ in target_config["demos"]]
            print(f"Unknown demo: {args.only}")
            print(f"Available for {target}: {', '.join(all_names)}")
            return []

    # In snapshot mode, skip TUI demos (they validate via checkpoints during GIF recording)
    if args.snapshot:
        original_count = len(demos)
        demos = [(t, n, s) for t, n, s in demos if n not in TUI_DEMOS]
        skipped = original_count - len(demos)
        if skipped:
            print(f"Skipping {skipped} TUI demo(s) (validate during GIF recording instead)")
        if not demos:
            print("No snapshotable demos found")
            return []

    # Handle --shell mode (interactive, must be sequential)
    if args.shell:
        tape_file, output_name, setup_func = demos[0]
        debug_dir = Path(tempfile.mkdtemp(prefix="wt-demo-"))
        env = _prepare_demo_env(debug_dir, output_name, themes[0], setup_func)
        print(f"Demo repo: {env.repo}")
        spawn_debug_shell(env)
        return []

    # TUI demos run sequentially (they use Zellij which conflicts when parallel)
    tui_demos = [(t, n, s) for t, n, s in demos if n in TUI_DEMOS]
    non_tui_demos = [(t, n, s) for t, n, s in demos if n not in TUI_DEMOS]

    failed = []
    succeeded = []

    def _record_one(tape_file, output_name, setup_func):
        try:
            elapsed = record_demo(
                tape_file,
                output_name,
                setup_func,
                mode_out_dir,
                themes,
                demo_sizes.get(output_name, demo_size),
                vhs_binary,
                args.text,
                args.snapshot,
            )
            succeeded.append((output_name, elapsed))
        except Exception as e:
            print(f"✗ {output_name}: {e}")
            failed.append(output_name)

    # Record non-TUI demos (parallel unless --sequential)
    if non_tui_demos:
        if args.sequential or len(non_tui_demos) == 1:
            print(f"\n[{target}] Recording {len(non_tui_demos)} demo(s) sequentially...")
            for tape_file, output_name, setup_func in non_tui_demos:
                _record_one(tape_file, output_name, setup_func)
        else:
            print(f"\n[{target}] Recording {len(non_tui_demos)} demo(s) in parallel...")
            max_workers = min(len(non_tui_demos), os.cpu_count() or 4)

            with ProcessPoolExecutor(max_workers=max_workers) as executor:
                futures = {
                    executor.submit(
                        record_demo,
                        tape_file,
                        output_name,
                        setup_func,
                        mode_out_dir,
                        themes,
                        demo_sizes.get(output_name, demo_size),
                        vhs_binary,
                        args.text,
                        args.snapshot,
                    ): output_name
                    for tape_file, output_name, setup_func in non_tui_demos
                }

                for future in as_completed(futures):
                    output_name = futures[future]
                    try:
                        elapsed = future.result()
                        succeeded.append((output_name, elapsed))
                    except Exception as e:
                        print(f"✗ {output_name}: {e}")
                        failed.append(output_name)

    # Record TUI demos sequentially (Zellij conflicts when parallel)
    if tui_demos:
        print(f"\n[{target}] Recording {len(tui_demos)} TUI demo(s) sequentially...")
        for tape_file, output_name, setup_func in tui_demos:
            _record_one(tape_file, output_name, setup_func)

    # Summary
    elapsed_total = time.monotonic() - t0
    mins, secs = divmod(int(elapsed_total), 60)
    passed_str = ", ".join(f"{n} ({e:.0f}s)" for n, e in succeeded)
    print(f"\n[{target}] {len(succeeded)} passed, {len(failed)} failed ({mins}m{secs:02d}s)")
    if succeeded:
        print(f"  Passed: {passed_str}")
    if failed:
        print(f"  Failed: {', '.join(failed)}")

    return failed


if __name__ == "__main__":
    main()
