from __future__ import annotations

import datetime as dt
import os
from pathlib import Path as P
import uuid
from pathlib import Path
from typing import Annotated, List, Optional

import typer

from iac.cli.apps.exec import app as exec_app
from iac.runbook.discovery import (
    parse_book_dirs,
    parse_module_dirs,
    resolve_book,
    split_module_function,
    split_book_step,
    book_id_for_path,
)
from iac.runbook.loader import load_book, parse_vars_kv, resolve_inputs
from iac.runbook.runner import RunOptions, run_book_sequential
from iac.runbook.model import Book, BookMeta, Step
from iac.cli.formatting import ensure_output_format, emit_structured, build_table

app = typer.Typer(
    add_completion=False,
    no_args_is_help=True,
    help="Execute commands, books, steps, and functions",
)

# Mount command runner under `iac run cmd`
app.add_typer(exec_app, name="", help="")


def _book_dirs() -> List[Path]:
    env = os.environ.get("IAC_BOOK_DIRS")
    return parse_book_dirs(env)


def _module_dirs() -> List[Path]:
    env = os.environ.get("IAC_MODULE_DIRS")
    return parse_module_dirs(env)


def _opts(
    quiet: bool,
    stdin_file: Optional[str],
    stdout_file: Optional[str],
    stderr_file: Optional[str],
    file_mode: str,
    artifacts_dir: Optional[str],
    capture: bool,
    print_captured: bool,
    pty: bool,
    killpg: bool,
    forward_signals: bool,
    timeout: Optional[float],
    retries: Optional[int],
    retry_delay: Optional[float],
    verbose: bool,
    run_id: str,
) -> RunOptions:
    return RunOptions(
        quiet=quiet,
        stdin_file=stdin_file,
        stdout_file=stdout_file,
        stderr_file=stderr_file,
        file_mode=file_mode,
        artifacts_dir=artifacts_dir,
        capture=capture,
        print_captured=print_captured,
        pty=pty,
        killpg=killpg,
        forward_signals=forward_signals,
        timeout_override=timeout,
        retries_override=retries,
        retry_delay_override=retry_delay,
        verbose=verbose,
        run_id=run_id,
    )


def _select_steps(
    steps: List[Step],
    *,
    from_step: Optional[str],
    to_step: Optional[str],
    only_steps: List[str],
    skip_steps: List[str],
    tags: List[str],
) -> List[Step]:
    by_name = {s.name: s for s in steps}
    selected = list(steps)

    if only_steps and (from_step or to_step):
        raise typer.BadParameter(
            "--only-step cannot be combined with --from-step/--to-step"
        )

    if only_steps:
        missing = [n for n in only_steps if n not in by_name]
        if missing:
            raise typer.BadParameter(
                f"Unknown --only-step value(s): {', '.join(missing)}"
            )
        only_set = set(only_steps)
        selected = [s for s in selected if s.name in only_set]
    else:
        start_idx = 0
        end_idx = len(selected) - 1
        if from_step:
            if from_step not in by_name:
                raise typer.BadParameter(f"Unknown --from-step: {from_step}")
            start_idx = next(i for i, s in enumerate(selected) if s.name == from_step)
        if to_step:
            if to_step not in by_name:
                raise typer.BadParameter(f"Unknown --to-step: {to_step}")
            end_idx = next(i for i, s in enumerate(selected) if s.name == to_step)
        if start_idx > end_idx:
            raise typer.BadParameter(
                "--from-step must come before --to-step in book order"
            )
        selected = selected[start_idx : end_idx + 1]

    if tags:
        tag_set = set(tags)
        selected = [s for s in selected if any(t in tag_set for t in s.tags)]

    if skip_steps:
        unknown_skip = [n for n in skip_steps if n not in by_name]
        if unknown_skip:
            raise typer.BadParameter(
                f"Unknown --skip-step value(s): {', '.join(unknown_skip)}"
            )
        skip_set = set(skip_steps)
        selected = [s for s in selected if s.name not in skip_set]

    if not selected:
        raise typer.BadParameter("Step selection produced an empty run set")
    return selected


def _results_summary(results: list) -> dict:
    out = {
        "total": len(results),
        "success": 0,
        "skipped": 0,
        "failed": 0,
        "failed_nonblocking": 0,
    }
    for r in results:
        out[r.status] = out.get(r.status, 0) + 1
    return out


def _compact_command(command: str, *, max_chars: int = 96) -> str:
    s = (command or "").strip()
    if not s:
        return ""
    lines = [ln.strip() for ln in s.splitlines() if ln.strip()]
    if not lines:
        return ""
    head = lines[0]
    if len(lines) > 1:
        head = f"{head} ... (+{len(lines) - 1} lines)"
    if len(head) > max_chars:
        return head[: max_chars - 3] + "..."
    return head


def _resolve_report_path(
    report: Optional[str], report_dir: Optional[str], run_id: str, fmt: str
) -> Optional[str]:
    if report:
        return str(P(report).expanduser().resolve())
    if report_dir:
        base = P(report_dir).expanduser().resolve()
        base.mkdir(parents=True, exist_ok=True)
        ext = "yaml" if fmt == "yaml" else "json"
        return str(base / f"{run_id}.{ext}")
    return None


def _write_report(path: Optional[str], payload: dict, fmt: str) -> Optional[str]:
    if not path:
        return None
    p = P(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    if fmt == "yaml":
        import yaml

        p.write_text(
            yaml.safe_dump(payload, sort_keys=False, default_flow_style=False),
            encoding="utf-8",
        )
    else:
        import json

        p.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    return str(p)


def _run_table(results: List[object], *, verbose: bool):
    if verbose:
        headers = [
            "STEP",
            "STATUS",
            "ATTEMPTS",
            "EXIT",
            "DURATION_MS",
            "CWD",
            "STDOUT",
            "STDERR",
            "COMMAND",
            "MESSAGE",
        ]
        rows = [
            [
                r.step,
                r.status,
                f"{r.attempts}/{r.max_attempts}",
                r.exit_code,
                r.duration_ms,
                r.cwd,
                r.stdout_file,
                r.stderr_file,
                _compact_command(r.command),
                r.message,
            ]
            for r in results
        ]
        return build_table(headers, rows)

    headers = ["STEP", "STATUS", "ATTEMPTS", "EXIT", "DURATION_MS", "MESSAGE"]
    rows = [
        [
            r.step,
            r.status,
            f"{r.attempts}/{r.max_attempts}",
            r.exit_code,
            r.duration_ms,
            r.message,
        ]
        for r in results
    ]
    return build_table(headers, rows)


@app.command("book")
def run_book(
    book: Annotated[str, typer.Argument(help="Book name or path (.yaml/.yml).")],
    input: Annotated[
        List[str],
        typer.Option(
            "--input",
            help="Provide runbook inputs (repeatable): --input key=value",
            show_default=False,
        ),
    ] = [],
    output: Annotated[
        str,
        typer.Option(
            "--output", help="Output format: table|json|yaml", show_default=True
        ),
    ] = "table",
    report: Annotated[
        Optional[str],
        typer.Option("--report", help="Write run report to this file path"),
    ] = None,
    report_dir: Annotated[
        Optional[str],
        typer.Option("--report-dir", help="Write run report under this directory"),
    ] = None,
    report_format: Annotated[
        str,
        typer.Option(
            "--report-format", help="Report format: json|yaml", show_default=True
        ),
    ] = "json",
    # runner options
    quiet: Annotated[
        bool, typer.Option("-q", "--quiet", help="Do not stream output")
    ] = False,
    stdin_file: Annotated[
        Optional[str],
        typer.Option("--stdin-file", help="Provide stdin bytes from file"),
    ] = None,
    stdout_file: Annotated[
        Optional[str], typer.Option("--stdout-file", help="Append stdout bytes to file")
    ] = None,
    stderr_file: Annotated[
        Optional[str], typer.Option("--stderr-file", help="Append stderr bytes to file")
    ] = None,
    file_mode: Annotated[
        str,
        typer.Option(
            "--file-mode", help="File write mode: append|truncate", show_default=True
        ),
    ] = "append",
    artifacts_dir: Annotated[
        Optional[str],
        typer.Option(
            "--artifacts-dir",
            help="Auto-write per-step stdout/stderr artifacts in this directory",
        ),
    ] = None,
    capture: Annotated[
        bool, typer.Option("--capture/--no-capture", help="Capture output in memory")
    ] = False,
    print_captured: Annotated[
        bool,
        typer.Option(
            "--print-captured/--no-print-captured",
            help="If --capture, print captured at end",
        ),
    ] = False,
    pty: Annotated[bool, typer.Option("--pty", help="Run under a PTY")] = False,
    timeout: Annotated[
        Optional[float],
        typer.Option(
            "--timeout", help="Override timeout seconds (applies to each step)"
        ),
    ] = None,
    retries: Annotated[
        Optional[int],
        typer.Option("--retries", help="Override retries for each step (>=0)"),
    ] = None,
    retry_delay: Annotated[
        Optional[float],
        typer.Option(
            "--retry-delay", help="Override retry delay seconds for each step (>=0)"
        ),
    ] = None,
    from_step: Annotated[
        Optional[str],
        typer.Option("--from-step", help="Start at this step name (inclusive)"),
    ] = None,
    to_step: Annotated[
        Optional[str],
        typer.Option("--to-step", help="Stop at this step name (inclusive)"),
    ] = None,
    only_step: Annotated[
        List[str],
        typer.Option("--only-step", help="Run only these step names (repeatable)"),
    ] = [],
    skip_step: Annotated[
        List[str],
        typer.Option("--skip-step", help="Skip these step names (repeatable)"),
    ] = [],
    tag: Annotated[
        List[str],
        typer.Option(
            "--tag", help="Run only steps containing any provided tag (repeatable)"
        ),
    ] = [],
    killpg: Annotated[
        bool, typer.Option("--killpg/--no-killpg", help="Use process-group termination")
    ] = True,
    forward_signals: Annotated[
        bool,
        typer.Option(
            "--forward-signals/--no-forward-signals", help="Forward signals to child"
        ),
    ] = True,
    verbose: Annotated[
        bool, typer.Option("-v", "--verbose", help="Verbose process metadata")
    ] = False,
) -> None:
    """Run all steps in a book sequentially."""
    dirs = _book_dirs()
    module_dirs = _module_dirs()
    output_fmt = ensure_output_format(output)
    if report_format not in ("json", "yaml"):
        raise typer.BadParameter("Invalid --report-format. Use json or yaml.")
    if file_mode not in ("append", "truncate"):
        raise typer.BadParameter("Invalid --file-mode. Use append or truncate.")
    if retries is not None and retries < 0:
        raise typer.BadParameter("--retries must be >= 0")
    if retry_delay is not None and retry_delay < 0:
        raise typer.BadParameter("--retry-delay must be >= 0")

    run_id = uuid.uuid4().hex[:12]
    report_path = _resolve_report_path(report, report_dir, run_id, report_format)
    bp = resolve_book(book, dirs)
    b = load_book(bp)
    vars_map = dict(resolve_inputs(b.inputs, input_cli=input))
    opts = _opts(
        quiet,
        stdin_file,
        stdout_file,
        stderr_file,
        file_mode,
        artifacts_dir,
        capture,
        print_captured,
        pty,
        killpg,
        forward_signals,
        timeout,
        retries,
        retry_delay,
        verbose,
        run_id,
    )
    selected_steps = _select_steps(
        b.steps,
        from_step=from_step,
        to_step=to_step,
        only_steps=only_step,
        skip_steps=skip_step,
        tags=tag,
    )
    run_book_obj = Book(
        meta=b.meta,
        vars=b.vars,
        inputs=b.inputs,
        requires=b.requires,
        outputs=b.outputs,
        hooks=b.hooks,
        sensitive=b.sensitive,
        steps=selected_steps,
    )

    started_at = dt.datetime.now(dt.timezone.utc).isoformat()
    results, final_outputs = run_book_sequential(
        run_book_obj,
        book_path=bp,
        vars_map=vars_map,
        opts=opts,
        report_path=report_path,
        module_dirs=module_dirs,
    )
    ended_at = dt.datetime.now(dt.timezone.utc).isoformat()

    exit_code = 0
    for r in results:
        if r.status == "failed":
            exit_code = r.exit_code or 1
            break

    payload = {
        "report_version": 1,
        "run_id": run_id,
        "kind": "book",
        "book": str(bp),
        "started_at": started_at,
        "ended_at": ended_at,
        "inputs": vars_map,
        "outputs": final_outputs,
        "selection": {
            "from_step": from_step or "",
            "to_step": to_step or "",
            "only_steps": only_step,
            "skip_steps": skip_step,
            "tags": tag,
        },
        "summary": _results_summary(results),
        "results": [r.__dict__ for r in results],
        "exit_code": exit_code,
    }
    written_report = _write_report(report_path, payload, report_format)
    if written_report:
        payload["report_path"] = written_report

    if output_fmt in ("json", "yaml"):
        emit_structured(payload, output_fmt)
        raise typer.Exit(exit_code)

    table = _run_table(results, verbose=verbose)
    typer.echo(f"Run Book: {bp}")
    typer.echo(table.get_string())
    summary = _results_summary(results)
    typer.echo(
        "Summary: "
        f"total={summary['total']} success={summary['success']} skipped={summary['skipped']} "
        f"failed_nonblocking={summary['failed_nonblocking']} failed={summary['failed']}"
    )
    failed = next((r for r in results if r.status == "failed"), None)
    if failed is not None:
        book_ref = book_id_for_path(bp)
        typer.echo(
            f"Failure: step={failed.step} exit={failed.exit_code} attempts={failed.attempts}/{failed.max_attempts}"
        )
        if failed.message:
            typer.echo(f"Reason: {failed.message}")
        typer.echo(f"Rerun Hint: iac run step {book_ref}@{failed.step}")
    if written_report:
        typer.echo(f"Report: {written_report}")
    typer.echo(f"Exit Code: {exit_code}")
    raise typer.Exit(exit_code)


@app.command("step")
def run_step(
    ref: Annotated[
        str,
        typer.Argument(
            help="Step reference 'book@step'. Book resolved from IAC_BOOK_DIRS or path."
        ),
    ],
    input: Annotated[
        List[str],
        typer.Option(
            "--input",
            help="Provide runbook inputs (repeatable): --input key=value",
            show_default=False,
        ),
    ] = [],
    output: Annotated[
        str,
        typer.Option(
            "--output", help="Output format: table|json|yaml", show_default=True
        ),
    ] = "table",
    report: Annotated[
        Optional[str],
        typer.Option("--report", help="Write run report to this file path"),
    ] = None,
    report_dir: Annotated[
        Optional[str],
        typer.Option("--report-dir", help="Write run report under this directory"),
    ] = None,
    report_format: Annotated[
        str,
        typer.Option(
            "--report-format", help="Report format: json|yaml", show_default=True
        ),
    ] = "json",
    # runner options
    quiet: Annotated[
        bool, typer.Option("-q", "--quiet", help="Do not stream output")
    ] = False,
    stdin_file: Annotated[
        Optional[str],
        typer.Option("--stdin-file", help="Provide stdin bytes from file"),
    ] = None,
    stdout_file: Annotated[
        Optional[str], typer.Option("--stdout-file", help="Append stdout bytes to file")
    ] = None,
    stderr_file: Annotated[
        Optional[str], typer.Option("--stderr-file", help="Append stderr bytes to file")
    ] = None,
    file_mode: Annotated[
        str,
        typer.Option(
            "--file-mode", help="File write mode: append|truncate", show_default=True
        ),
    ] = "append",
    artifacts_dir: Annotated[
        Optional[str],
        typer.Option(
            "--artifacts-dir",
            help="Auto-write per-step stdout/stderr artifacts in this directory",
        ),
    ] = None,
    capture: Annotated[
        bool, typer.Option("--capture/--no-capture", help="Capture output in memory")
    ] = False,
    print_captured: Annotated[
        bool,
        typer.Option(
            "--print-captured/--no-print-captured",
            help="If --capture, print captured at end",
        ),
    ] = False,
    pty: Annotated[bool, typer.Option("--pty", help="Run under a PTY")] = False,
    timeout: Annotated[
        Optional[float], typer.Option("--timeout", help="Override timeout seconds")
    ] = None,
    retries: Annotated[
        Optional[int],
        typer.Option("--retries", help="Override retries for this step (>=0)"),
    ] = None,
    retry_delay: Annotated[
        Optional[float],
        typer.Option("--retry-delay", help="Override retry delay seconds (>=0)"),
    ] = None,
    killpg: Annotated[
        bool, typer.Option("--killpg/--no-killpg", help="Use process-group termination")
    ] = True,
    forward_signals: Annotated[
        bool,
        typer.Option(
            "--forward-signals/--no-forward-signals", help="Forward signals to child"
        ),
    ] = True,
    verbose: Annotated[
        bool, typer.Option("-v", "--verbose", help="Verbose process metadata")
    ] = False,
) -> None:
    """Run a single step from a book (book@step)."""
    dirs = _book_dirs()
    module_dirs = _module_dirs()
    output_fmt = ensure_output_format(output)
    if report_format not in ("json", "yaml"):
        raise typer.BadParameter("Invalid --report-format. Use json or yaml.")
    if file_mode not in ("append", "truncate"):
        raise typer.BadParameter("Invalid --file-mode. Use append or truncate.")
    if retries is not None and retries < 0:
        raise typer.BadParameter("--retries must be >= 0")
    if retry_delay is not None and retry_delay < 0:
        raise typer.BadParameter("--retry-delay must be >= 0")

    run_id = uuid.uuid4().hex[:12]
    report_path = _resolve_report_path(report, report_dir, run_id, report_format)
    book_ref, step_name = split_book_step(ref)
    bp = resolve_book(book_ref, dirs)
    b = load_book(bp)

    step = next((s for s in b.steps if s.name == step_name), None)
    if step is None:
        typer.echo(f"Unknown step '{step_name}' in book {bp}", err=True)
        raise typer.Exit(2)

    vars_map = dict(resolve_inputs(b.inputs, input_cli=input))
    opts = _opts(
        quiet,
        stdin_file,
        stdout_file,
        stderr_file,
        file_mode,
        artifacts_dir,
        capture,
        print_captured,
        pty,
        killpg,
        forward_signals,
        timeout,
        retries,
        retry_delay,
        verbose,
        run_id,
    )

    started_at = dt.datetime.now(dt.timezone.utc).isoformat()
    run_book_obj = b.__class__(
        meta=b.meta,
        vars=b.vars,
        inputs=b.inputs,
        requires=b.requires,
        outputs=b.outputs,
        hooks=b.hooks,
        sensitive=b.sensitive,
        steps=[step],
    )
    results, final_outputs = run_book_sequential(
        run_book_obj,
        book_path=bp,
        vars_map=vars_map,
        opts=opts,
        report_path=report_path,
        module_dirs=module_dirs,
    )
    ended_at = dt.datetime.now(dt.timezone.utc).isoformat()
    res = results[0]

    exit_code = (
        0
        if res.status in ("success", "skipped", "failed_nonblocking")
        else (res.exit_code or 1)
    )

    payload = {
        "report_version": 1,
        "run_id": run_id,
        "kind": "step",
        "book": str(bp),
        "ref": ref,
        "started_at": started_at,
        "ended_at": ended_at,
        "result": res.__dict__,
        "outputs": final_outputs,
        "summary": _results_summary(results),
        "exit_code": exit_code,
    }
    written_report = _write_report(report_path, payload, report_format)
    if written_report:
        payload["report_path"] = written_report

    if output_fmt in ("json", "yaml"):
        emit_structured(payload, output_fmt)
        raise typer.Exit(exit_code)

    table = _run_table([res], verbose=verbose)
    typer.echo(f"Run Step: {bp.name}@{res.step}")
    typer.echo(table.get_string())
    if res.status == "failed":
        book_ref = book_id_for_path(bp)
        typer.echo(
            f"Failure: step={res.step} exit={res.exit_code} attempts={res.attempts}/{res.max_attempts}"
        )
        if res.message:
            typer.echo(f"Reason: {res.message}")
        typer.echo(f"Rerun Hint: iac run step {book_ref}@{res.step}")
    if written_report:
        typer.echo(f"Report: {written_report}")
    typer.echo(f"Exit Code: {exit_code}")
    raise typer.Exit(exit_code)


@app.command("fn")
def run_fn(
    ref: Annotated[
        str,
        typer.Argument(
            help="Function reference 'module@function'. Module resolved from IAC_MODULE_DIRS."
        ),
    ],
    var: Annotated[
        List[str],
        typer.Option(
            "--var", help="Override vars (repeatable): --var k=v", show_default=False
        ),
    ] = [],
    output: Annotated[
        str,
        typer.Option(
            "--output", help="Output format: table|json|yaml", show_default=True
        ),
    ] = "table",
    report: Annotated[
        Optional[str],
        typer.Option("--report", help="Write run report to this file path"),
    ] = None,
    report_dir: Annotated[
        Optional[str],
        typer.Option("--report-dir", help="Write run report under this directory"),
    ] = None,
    report_format: Annotated[
        str,
        typer.Option(
            "--report-format", help="Report format: json|yaml", show_default=True
        ),
    ] = "json",
    # runner options
    quiet: Annotated[
        bool, typer.Option("-q", "--quiet", help="Do not stream output")
    ] = False,
    stdin_file: Annotated[
        Optional[str],
        typer.Option("--stdin-file", help="Provide stdin bytes from file"),
    ] = None,
    stdout_file: Annotated[
        Optional[str], typer.Option("--stdout-file", help="Append stdout bytes to file")
    ] = None,
    stderr_file: Annotated[
        Optional[str], typer.Option("--stderr-file", help="Append stderr bytes to file")
    ] = None,
    file_mode: Annotated[
        str,
        typer.Option(
            "--file-mode", help="File write mode: append|truncate", show_default=True
        ),
    ] = "append",
    artifacts_dir: Annotated[
        Optional[str],
        typer.Option(
            "--artifacts-dir",
            help="Auto-write per-step stdout/stderr artifacts in this directory",
        ),
    ] = None,
    capture: Annotated[
        bool, typer.Option("--capture/--no-capture", help="Capture output in memory")
    ] = False,
    print_captured: Annotated[
        bool,
        typer.Option(
            "--print-captured/--no-print-captured",
            help="If --capture, print captured at end",
        ),
    ] = False,
    pty: Annotated[bool, typer.Option("--pty", help="Run under a PTY")] = False,
    timeout: Annotated[
        Optional[float], typer.Option("--timeout", help="Override timeout seconds")
    ] = None,
    retries: Annotated[
        Optional[int],
        typer.Option("--retries", help="Override retries for this function (>=0)"),
    ] = None,
    retry_delay: Annotated[
        Optional[float],
        typer.Option("--retry-delay", help="Override retry delay seconds (>=0)"),
    ] = None,
    killpg: Annotated[
        bool, typer.Option("--killpg/--no-killpg", help="Use process-group termination")
    ] = True,
    forward_signals: Annotated[
        bool,
        typer.Option(
            "--forward-signals/--no-forward-signals", help="Forward signals to child"
        ),
    ] = True,
    verbose: Annotated[
        bool, typer.Option("-v", "--verbose", help="Verbose process metadata")
    ] = False,
) -> None:
    """Run a Python module function directly (module@function)."""
    try:
        _module_name, fn_name = split_module_function(ref)
    except ValueError as e:
        raise typer.BadParameter(str(e)) from e

    module_dirs = _module_dirs()
    output_fmt = ensure_output_format(output)
    if report_format not in ("json", "yaml"):
        raise typer.BadParameter("Invalid --report-format. Use json or yaml.")
    if file_mode not in ("append", "truncate"):
        raise typer.BadParameter("Invalid --file-mode. Use append or truncate.")
    if retries is not None and retries < 0:
        raise typer.BadParameter("--retries must be >= 0")
    if retry_delay is not None and retry_delay < 0:
        raise typer.BadParameter("--retry-delay must be >= 0")

    run_id = uuid.uuid4().hex[:12]
    report_path = _resolve_report_path(report, report_dir, run_id, report_format)
    vars_map = parse_vars_kv(var)
    opts = _opts(
        quiet,
        stdin_file,
        stdout_file,
        stderr_file,
        file_mode,
        artifacts_dir,
        capture,
        print_captured,
        pty,
        killpg,
        forward_signals,
        timeout,
        retries,
        retry_delay,
        verbose,
        run_id,
    )

    synthetic_step = Step(
        name=fn_name,
        mode="module",
        module=ref,
        on_fail="halt",
    )
    synthetic_book = Book(
        meta=BookMeta(name="__fn__"),
        vars={},
        inputs={},
        requires=[],
        outputs={},
        hooks={},
        sensitive=[],
        steps=[synthetic_step],
    )
    synthetic_book_path = P.cwd() / ".iac" / "__fn__.yaml"

    started_at = dt.datetime.now(dt.timezone.utc).isoformat()
    results, final_outputs = run_book_sequential(
        synthetic_book,
        book_path=synthetic_book_path,
        vars_map=vars_map,
        opts=opts,
        report_path=report_path,
        module_dirs=module_dirs,
    )
    ended_at = dt.datetime.now(dt.timezone.utc).isoformat()
    res = results[0]

    exit_code = (
        0
        if res.status in ("success", "skipped", "failed_nonblocking")
        else (res.exit_code or 1)
    )

    payload = {
        "report_version": 1,
        "run_id": run_id,
        "kind": "fn",
        "ref": ref,
        "started_at": started_at,
        "ended_at": ended_at,
        "vars": vars_map,
        "outputs": final_outputs,
        "result": res.__dict__,
        "summary": _results_summary(results),
        "exit_code": exit_code,
    }
    written_report = _write_report(report_path, payload, report_format)
    if written_report:
        payload["report_path"] = written_report

    if output_fmt in ("json", "yaml"):
        emit_structured(payload, output_fmt)
        raise typer.Exit(exit_code)

    table = _run_table([res], verbose=verbose)
    typer.echo(f"Run Function: {ref}")
    typer.echo(table.get_string())
    if res.status == "failed":
        typer.echo(
            f"Failure: fn={ref} exit={res.exit_code} attempts={res.attempts}/{res.max_attempts}"
        )
        if res.message:
            typer.echo(f"Reason: {res.message}")
        typer.echo(f"Rerun Hint: iac run fn {ref}")
    if written_report:
        typer.echo(f"Report: {written_report}")
    typer.echo(f"Exit Code: {exit_code}")
    raise typer.Exit(exit_code)
