"""
RelationalAI CLI - Tools for working with semantic models.
For more documentation on these commands, visit: https://docs.relationalai.com
"""

from __future__ import annotations

import sys
import json
from contextlib import contextmanager
from collections.abc import Iterator
from pathlib import Path
from typing import Any, cast, TYPE_CHECKING

import click

if TYPE_CHECKING:
    from relationalai.client.client_sync import ClientSync
    from relationalai.config.config import Config

from rich.console import Console
from rich.panel import Panel

from .markup import tip
from .error_handlers import validate_session, get_connection_suggestion
from .execution_diagnostics import (
    disable_execution_logging_buffer,
    enable_execution_logging_buffer,
    flush_execution_logging_buffer,
    print_execution_metrics_if_enabled,
)
from ...util.docutils import include_in_docs

console = Console()


class RaiGroup(click.Group):
    """Custom CLI group that formats subcommands in spaced sections."""

    def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
        # Store both the raw command name and its display label.
        rows: list[tuple[str, str, str]] = []
        for name in self.list_commands(ctx):
            cmd = self.get_command(ctx, name)
            if cmd is None or getattr(cmd, "hidden", False):
                continue
            label = name
            # Click uses `ctx.color=None` to mean "auto"; only treat color as disabled
            # when it is explicitly False.
            if getattr(ctx, "color", None) is not False:
                if ":" in name:
                    prefix, rest = name.split(":", 1)
                    label = click.style(prefix + ":", dim=False, fg="bright_blue") + rest
                else:
                    # Color top-level commands (e.g. `connect`, `init`) so they read
                    # clearly as actionable commands in the help output.
                    label = click.style(name, dim=False, fg="bright_blue")
            rows.append((name, label, cmd.get_short_help_str()))

        if not rows:
            return

        def _by_prefix(prefix: str | None) -> list[tuple[str, str]]:
            if prefix is None:
                return [(label, h) for (n, label, h) in rows if ":" not in n]
            return [(label, h) for (n, label, h) in rows if n.startswith(prefix)]

        sections: list[list[tuple[str, str]]] = []

        # Top-level commands first.
        sections.append(_by_prefix(None))

        # Grouped commands next (e.g. `jobs:*`, `reasoners:*`, ...), discovered dynamically.
        prefixes = sorted({n.split(":", 1)[0] for (n, _label, _h) in rows if ":" in n})
        for p in prefixes:
            sections.append(_by_prefix(f"{p}:"))

        # Drop empty sections.
        sections = [s for s in sections if s]

        with formatter.section("Commands"):
            for i, sec in enumerate(sections):
                if i:
                    formatter.write_paragraph()
                formatter.write_dl(sec)


@contextmanager
def _cli_connect_sync(config: "Config") -> Iterator["ClientSync"]:
    """Connect a sync client and (optionally) emit execution diagnostics."""
    from relationalai.client import connect_sync

    # If enabled, execution logs are buffered and later flushed on context close.
    handler = enable_execution_logging_buffer(config)
    ctx = click.get_current_context(silent=True)
    if ctx is not None:
        ctx.ensure_object(dict)
        if handler is not None:
            ctx.obj["_execution_log_handler"] = handler
        ctx.obj["_execution_diag_config"] = config

    with connect_sync(config=config) as client:
        try:
            yield client
        finally:
            # Capture metrics for end-of-command printing (after tables/spinners).
            if ctx is not None:
                ctx.obj["_execution_metrics"] = getattr(client, "execution_metrics", None)


def _is_interactive() -> bool:
    """Return True when CLI can prompt interactively."""
    try:
        return bool(getattr(sys.stdin, "isatty", lambda: False)())
    except Exception:
        return False


def _emit_cli_error(*, name: str, message: str, details: str | None = None) -> None:
    """Emit a pretty diagnostic for CLI failures (stderr)."""
    from relationalai.util.error import Diagnostic, Part, RichEmitter

    parts: list[Part] = [details] if details else []
    diag = Diagnostic(name=name, message=message, parts=parts, severity="error")
    sys.stderr.write(RichEmitter().emit(diag).rstrip() + "\n")


def _emit_cli_error_handled(
    *,
    name: str,
    message: str,
    error: Exception,
    params: dict[str, Any] | None = None,
) -> None:
    """Emit a CLI error using the registered error handlers (first match wins)."""
    from relationalai.tools.cli.error_handlers import CliErrorContext, DEFAULT_CLI_ERROR_HANDLER_CHAIN

    ctx = CliErrorContext(command=name, params=params or {})
    details = DEFAULT_CLI_ERROR_HANDLER_CHAIN.format_details(error, ctx)
    _emit_cli_error(name=name, message=message, details=details)


def _cli_fail(
    *,
    name: str,
    message: str,
    error: Exception,
    click_message: str,
    params: dict[str, Any] | None = None,
) -> "None":
    """Emit a handled error then raise a ClickException."""
    if isinstance(error, click.Abort):
        # Treat Ctrl+C / ESC aborts as a clean cancellation (no diagnostic).
        raise click.exceptions.Exit(0) from error
    _emit_cli_error_handled(name=name, message=message, error=error, params=params)
    raise click.ClickException(click_message) from error


def _job_include_from_flags(includes: tuple[str, ...]):
    """Convert repeated `--include` flags into a typed `JobIncludeDict`."""
    if not includes:
        return None

    from relationalai.services.jobs.util import JobIncludeDict

    inc: JobIncludeDict = {}
    for k in includes:
        kk = str(k).strip().lower()
        if kk == "details":
            inc["details"] = True
        elif kk == "events":
            inc["events"] = True
        elif kk == "artifacts":
            inc["artifacts"] = True
        elif kk == "problems":
            inc["problems"] = True
        elif kk == "raw":
            inc["raw"] = True
    return inc


def _jobs_get_with_spinner(jobs: Any, job_type: str, job_id: str, *, include: Any = None) -> Any:
    from relationalai.tools.cli.components import WaitSpinner

    with WaitSpinner(f"Fetching job {job_id}..."):
        return jobs.get(str(job_type), str(job_id), include=include)


def _jobs_wait_with_spinner(
    jobs: Any,
    job_type: str,
    job_id: str,
    *,
    timeout_s: float,
    poll_interval_s: float,
) -> Any:
    from relationalai.tools.cli.components import WaitSpinner

    with WaitSpinner(f"Waiting for job {job_id}..."):
        return jobs.wait(str(job_type), str(job_id), timeout_s=timeout_s, poll_interval_s=poll_interval_s)


def _print_job_background_instructions(*, job_type_label: str, job_id: str, created: bool = True) -> None:
    if created:
        console.print("")
        console.print("Job started and will continue running in the background.")
    console.print(f"Wait for completion: [green]rai jobs:wait --type {job_type_label} --id {job_id}[/green]")
    console.print(tip("use [cyan]--wait[/cyan] flag to block until job is in COMPLETED state."))


def _ensure_job_identity(
    jobs: Any,
    *,
    config: "Config",
    job_type: str | None,
    job_id: str | None,
    interactive: bool,
    prompt: str,
    only_active: bool = False,
) -> tuple[str, str] | None:
    """Return `(job_type, job_id)` for a job, prompting when needed.

    Supports:
    - direct args (`--type`, `--id`)
    - providing only `--id` (best-effort resolve type across types)
    - interactive fuzzy selection when args are missing/ambiguous
    """
    from relationalai.tools.cli.flows import select_job

    rt = job_type
    jid = job_id

    if jid and not (rt and str(rt).strip()):
        # Best-effort type resolution across types.
        xs: list[Any] = []
        try:
            xs = jobs.list(None, job_id=jid, limit=5)
        except Exception:
            xs = []
        if len(xs) == 1:
            rt = getattr(xs[0], "reasoner_type", None)
        elif len(xs) > 1 and interactive:
            picked = select_job(
                jobs,
                config=config,
                reasoner_type=None,
                job_id=jid,
                interactive=interactive,
                console=console,
                prompt=prompt,
                only_active=only_active,
            )
            if picked is None:
                return None
            rt, jid = picked

    if not (rt and str(rt).strip() and jid and str(jid).strip()):
        if not interactive:
            raise click.ClickException("--type and --id are required (or run interactively).")
        picked = select_job(
            jobs,
            config=config,
            reasoner_type=rt,
            job_id=jid,
            interactive=interactive,
            console=console,
            prompt=prompt,
            only_active=only_active,
        )
        if picked is None:
            return None
        rt, jid = picked

    # The job record may contain internal discriminators (LOGIC/SOLVER/ML). Convert to a
    # stable public label before calling `client.jobs` (which accepts public labels).
    from relationalai.tools.cli.job_types import job_type_label

    t_public = job_type_label(str(rt)) or str(rt)
    return str(t_public), str(jid)


def _require_reasoner_identity_non_interactive(
    *,
    reasoner_type: str | None,
    reasoner_name: str | None,
    interactive: bool,
) -> None:
    """Require `--type` and `--name` in non-interactive mode."""
    if interactive:
        return
    if not (reasoner_type or "").strip():
        raise click.ClickException("Reasoner type is required. Use --type.")
    if not (reasoner_name or "").strip():
        raise click.ClickException("Reasoner name is required. Use --name.")


def _load_config():
    """Load config using the unified create_config() factory (rai yaml → snowflake toml → dbt profiles)."""
    try:
        from relationalai.config import create_config

        return create_config()
    except ModuleNotFoundError as e:
        raise click.ClickException(
            "Failed to load config. Ensure optional config dependencies are installed "
            "(e.g. `pydantic-settings`) and that your configuration is valid."
        ) from e
    except Exception as e:
        raise click.ClickException(f"Failed to load config: {e}") from e


@click.group(invoke_without_command=True, cls=RaiGroup, context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option()
@click.pass_context
def cli(ctx: click.Context):
    """RelationalAI CLI - Tools for working with semantic models. For more documentation on these commands, visit: https://docs.relationalai.com"""
    ctx.ensure_object(dict)

    # Flush buffered execution logs / metrics at the very end of the command.
    # Using `call_on_close` ensures this runs after the command has printed tables,
    # and also runs on failures.
    if not ctx.obj.get("_execution_diag_on_close_registered"):
        ctx.obj["_execution_diag_on_close_registered"] = True

        def _flush_execution_diagnostics() -> None:
            cfg = ctx.obj.get("_execution_diag_config")
            handler = ctx.obj.get("_execution_log_handler")

            flush_execution_logging_buffer(handler, cfg=cfg)
            disable_execution_logging_buffer(handler)

            if cfg is not None:
                print_execution_metrics_if_enabled(cfg, ctx.obj.get("_execution_metrics"))

            ctx.obj.pop("_execution_log_handler", None)
            ctx.obj.pop("_execution_metrics", None)
            ctx.obj.pop("_execution_diag_config", None)

        ctx.call_on_close(_flush_execution_diagnostics)

    if ctx.invoked_subcommand is None:
        # Show help if no command provided
        click.echo(ctx.get_help())


# Help static type checkers: click decorators return Group instances.
cli = cast(RaiGroup, cli)


@cli.command(hidden=True)
def analyze():
    """Check for errors, warnings, and performance issues in the models."""
    console.print("analyze")


@cli.command(hidden=True)
def build():
    """Create artifacts, show compilation errors, and produce SQL for the models."""
    console.print("build")


@cli.command()
@include_in_docs
def connect():
    """Validate config and database connection."""
    config = _load_config()

    try:
        from .display import show_connect_panel
        connection = show_connect_panel(config, console)
    except Exception as e:
        raise click.ClickException(f"Failed to resolve default connection: {e}") from e

    session = None
    err: Exception | None = None
    try:
        session = connection.get_session()
        warehouse = getattr(connection, "warehouse", None)
        validate_session(session, warehouse=warehouse)
    except Exception as e:
        session = None
        err = e

    if err is None:
        console.print("[bold green]✓[/bold green] Connected successfully\n")
    else:
        console.print("[bold red]✗[/bold red] Connection failed\n")
        suggestion = get_connection_suggestion(str(err))
        if suggestion:
            console.print(Panel(suggestion, title="[bold yellow]Suggestion[/bold yellow]", border_style="yellow", padding=(1, 2)))
        console.print("[dim]Run [bold]rai config:explain[/bold] to inspect your active config and where each value comes from.[/dim]\n")
        raise click.ClickException(str(err)) from err

    return session


@cli.command(hidden=True)
def deploy():
    """Deploy models/views to the database and produce SQL for execution."""
    console.print("deploy")


@cli.command(hidden=True)
def explore():
    """Model explorer for visualization of data."""
    console.print("explore")


@cli.command()
@click.option("--migrate", is_flag=True, default=False, help="Migrate raiconfig.toml to raiconfig.yaml immediately.")
@include_in_docs
def init(migrate: bool):
    """Create or validate raiconfig.yaml."""
    from .init_wizard import run_init_wizard
    run_init_wizard(migrate=migrate)


@cli.command(name="config:explain")
@click.option("--verbose", "-v", is_flag=True, default=False, help="Show full provenance details.")
@include_in_docs
def config_explain(verbose: bool):
    """Show the active config and where each value comes from."""
    from .init_wizard import run_explain
    config = _load_config()
    run_explain(config, verbose=verbose)


@cli.command(hidden=True)
@click.option("--uninstall", is_flag=True, help="Nukes the database")
def clean(uninstall: bool):
    """Clean up build folder and remove artifacts."""
    console.print("clean")


@cli.command(hidden=True)
def test():
    """Run constraints and tests."""
    console.print("test")


@cli.command(name="reasoners:create")
@click.option(
    "--type",
    "reasoner_type",
    required=False,
    default=None,
    help="Reasoner type (Logic / Prescriptive / Predictive). If omitted, you'll be prompted to select.",
)
@click.option(
    "--name",
    "reasoner_name",
    default=None,
    help="Reasoner name. If omitted, you'll be prompted to enter one.",
)
@click.option(
    "--size",
    "reasoner_size",
    default=None,
    help="Reasoner size. If omitted, you can choose (or use config default).",
)
@click.option(
    "--auto-suspend-mins",
    type=int,
    default=None,
    help="Auto-suspend after N minutes of inactivity (optional).",
)
@click.option(
    "--await-storage-vacuum/--no-await-storage-vacuum",
    default=None,
    help="Wait for storage vacuum work before suspending the reasoner (use --await-storage-vacuum to enable, --no-await-storage-vacuum to disable).",
)
@click.option(
    "--timeout-s",
    type=int,
    default=900,
    show_default=True,
    help="Timeout in seconds while waiting until the reasoner is READY.",
)
@click.option(
    "--wait",
    is_flag=True,
    help="Wait until the reasoner is READY before returning.",
)
@include_in_docs
def reasoners_create(
    reasoner_type: str | None,
    reasoner_name: str | None,
    reasoner_size: str | None,
    auto_suspend_mins: int | None,
    await_storage_vacuum: bool | None,
    timeout_s: int,
    wait: bool,
):
    """Create a reasoner."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_reasoner
    from relationalai.tools.cli.flows import ensure_reasoner_name, ensure_reasoner_size, ensure_reasoner_type

    config = _load_config()
    reasoner = None
    t_norm: str | None = None
    t_label: str | None = None
    name: str | None = None
    size: str | None = None

    try:
        interactive = _is_interactive()

        # Step-by-step interactive flow (type -> name -> size)
        t_norm, t_label = ensure_reasoner_type(reasoner_type, interactive=interactive, console=console)
        name = ensure_reasoner_name(reasoner_name, interactive=interactive, console=console)

        with _cli_connect_sync(config) as client:
            size = ensure_reasoner_size(client.reasoners, reasoner_size, interactive=interactive, console=console)

            size_display = size or "default"
            if wait:
                with WaitSpinner(
                    f"Creating {t_label} reasoner '{name}' (size {size_display})... (may take a few minutes)"
                ):
                    reasoner = client.reasoners.create_ready(
                        reasoner_type=t_norm,
                        reasoner_name=name,
                        reasoner_size=size,
                        auto_suspend_mins=auto_suspend_mins,
                        await_storage_vacuum=await_storage_vacuum,
                        timeout_s=timeout_s,
                    )
            else:
                op = None
                with WaitSpinner(f"Starting {t_label} reasoner create for '{name}' (size {size_display})..."):
                    op = client.reasoners.create(
                        reasoner_type=t_norm,
                        reasoner_name=name,
                        reasoner_size=size,
                        auto_suspend_mins=auto_suspend_mins,
                        await_storage_vacuum=await_storage_vacuum,
                        settings=None,
                    )
                assert op is not None
                console.print(f"[green]create: OK[/green] (operation_id={op.id})")
                console.print("")
                console.print(
                    "Reasoner creation has started and will continue in the background."
                )
                console.print(f"Check status: [green]rai reasoners:get --type {t_label} --name {name}[/green]")
                console.print(tip("use [cyan]--wait[/cyan] flag to block until reasoner is in READY status."))
                return
    except Exception as e:
        _cli_fail(
            name="reasoners:create",
            message="Failed to create reasoner.",
            error=e,
            click_message="Reasoners create failed.",
            params={
                "reasoner_type": t_norm or reasoner_type,
                "reasoner_name": name or reasoner_name,
                "reasoner_size": size or reasoner_size,
            },
        )

    show_reasoner(reasoner)


@cli.command(name="reasoners:delete")
@click.option(
    "--type",
    "reasoner_type",
    required=False,
    default=None,
    help="Reasoner type filter (Logic/Prescriptive/Predictive) (optional).",
)
@click.option("--name", "reasoner_name", required=False, default=None, help="Reasoner name (optional).")
@include_in_docs
def reasoners_delete(reasoner_type: str | None, reasoner_name: str | None):
    """Delete a reasoner."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_reasoner
    from relationalai.tools.cli.flows import ensure_reasoner_name, ensure_reasoner_type, select_reasoner

    config = _load_config()
    try:
        from relationalai.services.reasoners.models import Reasoner

        interactive = _is_interactive()
        before: Reasoner | None = None

        _require_reasoner_identity_non_interactive(
            reasoner_type=reasoner_type, reasoner_name=reasoner_name, interactive=interactive
        )

        # If the user provided exactly one of (type, name), prompt for the other.
        # This keeps the CLI honest: lifecycle operations address a specific (type,name).
        if reasoner_name and not reasoner_type:
            t_norm, t_label = ensure_reasoner_type(None, interactive=interactive, console=console)
            name = ensure_reasoner_name(reasoner_name, interactive=interactive, console=console)
            with _cli_connect_sync(config) as client:
                with WaitSpinner(f"Deleting {t_label} reasoner '{name}'..."):
                    before = client.reasoners.get(reasoner_type=t_norm, reasoner_name=name)
                    client.reasoners.delete(reasoner_type=t_norm, reasoner_name=name)
            if before is None:
                before = Reasoner(name=name, type=t_norm)
            show_reasoner(Reasoner(**{**before.__dict__, "state": "DELETED"}))
            return

        # If both provided, delete directly (still validates/normalizes type).
        if reasoner_type and reasoner_name:
            t_norm, t_label = ensure_reasoner_type(reasoner_type, interactive=interactive, console=console)
            with _cli_connect_sync(config) as client:
                with WaitSpinner(f"Deleting {t_label} reasoner '{reasoner_name}'..."):
                    before = client.reasoners.get(reasoner_type=t_norm, reasoner_name=reasoner_name)
                    client.reasoners.delete(reasoner_type=t_norm, reasoner_name=reasoner_name)
            if before is None:
                before = Reasoner(name=reasoner_name, type=t_norm)
            show_reasoner(Reasoner(**{**before.__dict__, "state": "DELETED"}))
            return

        with _cli_connect_sync(config) as client:
            picked = select_reasoner(
                client.reasoners,
                reasoner_type=reasoner_type,
                reasoner_name=reasoner_name,
                interactive=interactive,
                console=console,
                prompt="Select a reasoner to delete:",
            )
            if picked is None:
                return
            r_type, r_name = picked
            t_norm, t_label = ensure_reasoner_type(r_type, interactive=interactive, console=console)
            with WaitSpinner(f"Deleting {t_label} reasoner '{r_name}'..."):
                before = client.reasoners.get(reasoner_type=t_norm, reasoner_name=r_name)
                client.reasoners.delete(reasoner_type=t_norm, reasoner_name=r_name)
            if before is None:
                before = Reasoner(name=r_name, type=t_norm)
            show_reasoner(Reasoner(**{**before.__dict__, "state": "DELETED"}))
    except Exception as e:
        _cli_fail(
            name="reasoners:delete",
            message="Failed to delete reasoner.",
            error=e,
            click_message="Reasoners delete failed.",
            params={"reasoner_type": reasoner_type, "reasoner_name": reasoner_name},
        )


@cli.command(name="reasoners:alter")
@click.option(
    "--type",
    "reasoner_type",
    required=False,
    default=None,
    help="Reasoner type (Logic/Prescriptive/Predictive) (optional).",
)
@click.option("--name", "reasoner_name", required=False, default=None, help="Reasoner name (optional).")
@click.option(
    "--auto-suspend-mins",
    type=int,
    default=None,
    help="New auto-suspend timeout in minutes.",
)
@include_in_docs
def reasoners_alter(reasoner_type: str | None, reasoner_name: str | None, auto_suspend_mins: int | None):
    """Alter a reasoner's settings (e.g. auto-suspend timeout)."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_reasoner
    from relationalai.tools.cli.flows import ensure_reasoner_type, select_reasoner

    if auto_suspend_mins is None:
        raise click.UsageError("At least one setting to alter must be provided (e.g. --auto-suspend-mins).")

    config = _load_config()
    try:
        interactive = _is_interactive()

        _require_reasoner_identity_non_interactive(
            reasoner_type=reasoner_type, reasoner_name=reasoner_name, interactive=interactive
        )

        if reasoner_type and reasoner_name:
            t_norm, t_label = ensure_reasoner_type(reasoner_type, interactive=interactive, console=console)
            with _cli_connect_sync(config) as client:
                with WaitSpinner(f"Altering {t_label} reasoner '{reasoner_name}'..."):
                    if auto_suspend_mins is not None:
                        client.reasoners.alter_auto_suspend_mins(
                            reasoner_type=t_norm,
                            reasoner_name=reasoner_name,
                            auto_suspend_mins=auto_suspend_mins,
                        )
                r = client.reasoners.get(reasoner_type=t_norm, reasoner_name=reasoner_name)
            show_reasoner(r)
            return

        with _cli_connect_sync(config) as client:
            picked = select_reasoner(
                client.reasoners,
                reasoner_type=reasoner_type,
                reasoner_name=reasoner_name,
                interactive=interactive,
                console=console,
                prompt="Select a reasoner to alter:",
            )
            if picked is None:
                return
            r_type, r_name = picked
            t_norm, t_label = ensure_reasoner_type(r_type, interactive=interactive, console=console)
            with WaitSpinner(f"Altering {t_label} reasoner '{r_name}'..."):
                if auto_suspend_mins is not None:
                    client.reasoners.alter_auto_suspend_mins(
                        reasoner_type=t_norm,
                        reasoner_name=r_name,
                        auto_suspend_mins=auto_suspend_mins,
                    )
            r = client.reasoners.get(reasoner_type=t_norm, reasoner_name=r_name)
            show_reasoner(r)
    except Exception as e:
        _cli_fail(
            name="reasoners:alter",
            message="Failed to alter reasoner.",
            error=e,
            click_message="Reasoners alter failed.",
            params={"reasoner_type": reasoner_type, "reasoner_name": reasoner_name, "auto_suspend_mins": auto_suspend_mins},
        )


@cli.command(name="reasoners:suspend")
@click.option(
    "--type",
    "reasoner_type",
    required=False,
    default=None,
    help="Reasoner type filter (Logic/Prescriptive/Predictive) (optional).",
)
@click.option("--name", "reasoner_name", required=False, default=None, help="Reasoner name (optional).")
@include_in_docs
def reasoners_suspend(reasoner_type: str | None, reasoner_name: str | None):
    """Suspend a reasoner."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_reasoner
    from relationalai.tools.cli.flows import ensure_reasoner_name, ensure_reasoner_type, select_reasoner

    config = _load_config()
    try:
        from relationalai.services.reasoners.models import Reasoner

        interactive = _is_interactive()
        r_after: Reasoner | None = None

        _require_reasoner_identity_non_interactive(
            reasoner_type=reasoner_type, reasoner_name=reasoner_name, interactive=interactive
        )

        if reasoner_name and not reasoner_type:
            t_norm, t_label = ensure_reasoner_type(None, interactive=interactive, console=console)
            name = ensure_reasoner_name(reasoner_name, interactive=interactive, console=console)
            with _cli_connect_sync(config) as client:
                with WaitSpinner(f"Suspending {t_label} reasoner '{name}'..."):
                    client.reasoners.suspend(reasoner_type=t_norm, reasoner_name=name)
                    r_after = client.reasoners.get(reasoner_type=t_norm, reasoner_name=name)
            show_reasoner(r_after or Reasoner(name=name, type=t_norm, state="SUSPENDED"))
            return

        if reasoner_type and reasoner_name:
            t_norm, t_label = ensure_reasoner_type(reasoner_type, interactive=interactive, console=console)
            with _cli_connect_sync(config) as client:
                with WaitSpinner(f"Suspending {t_label} reasoner '{reasoner_name}'..."):
                    client.reasoners.suspend(reasoner_type=t_norm, reasoner_name=reasoner_name)
                    r_after = client.reasoners.get(reasoner_type=t_norm, reasoner_name=reasoner_name)
            show_reasoner(r_after or Reasoner(name=reasoner_name, type=t_norm, state="SUSPENDED"))
            return

        with _cli_connect_sync(config) as client:
            picked = select_reasoner(
                client.reasoners,
                reasoner_type=reasoner_type,
                reasoner_name=reasoner_name,
                exclude_states={"SUSPENDED"},
                interactive=interactive,
                console=console,
                prompt="Select a reasoner to suspend:",
            )
            if picked is None:
                return
            r_type, r_name = picked
            t_norm, t_label = ensure_reasoner_type(r_type, interactive=interactive, console=console)
            with WaitSpinner(f"Suspending {t_label} reasoner '{r_name}'..."):
                client.reasoners.suspend(reasoner_type=t_norm, reasoner_name=r_name)
                r_after = client.reasoners.get(reasoner_type=t_norm, reasoner_name=r_name)
            show_reasoner(r_after or Reasoner(name=r_name, type=t_norm, state="SUSPENDED"))
    except Exception as e:
        _cli_fail(
            name="reasoners:suspend",
            message="Failed to suspend reasoner.",
            error=e,
            click_message="Reasoners suspend failed.",
            params={"reasoner_type": reasoner_type, "reasoner_name": reasoner_name},
        )


@cli.command(name="reasoners:resume")
@click.option(
    "--type",
    "reasoner_type",
    required=False,
    default=None,
    help="Reasoner type filter (Logic/Prescriptive/Predictive) (optional).",
)
@click.option("--name", "reasoner_name", required=False, default=None, help="Reasoner name (optional).")
@click.option(
    "--timeout-s",
    type=int,
    default=900,
    show_default=True,
    help="Timeout in seconds while waiting until the reasoner is READY (used with --wait).",
)
@click.option(
    "--wait",
    is_flag=True,
    help="Wait until the reasoner is READY before returning.",
)
@include_in_docs
def reasoners_resume(reasoner_type: str | None, reasoner_name: str | None, timeout_s: int, wait: bool):
    """Resume a reasoner."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_reasoner
    from relationalai.tools.cli.flows import ensure_reasoner_name, ensure_reasoner_type, select_reasoner

    config = _load_config()
    try:
        from relationalai.services.reasoners.models import Reasoner

        interactive = _is_interactive()

        _require_reasoner_identity_non_interactive(
            reasoner_type=reasoner_type, reasoner_name=reasoner_name, interactive=interactive
        )

        def _resume_one(*, client: Any, t_norm: str, t_label: str, name: str) -> None:
            if wait:
                r = None
                with WaitSpinner(f"Resuming {t_label} reasoner '{name}'... (may take a few minutes)"):
                    r = client.reasoners.resume_ready(reasoner_type=t_norm, reasoner_name=name, timeout_s=timeout_s)
                assert r is not None
                show_reasoner(r)
                return

            op = None
            with WaitSpinner(f"Starting {t_label} reasoner resume for '{name}'..."):
                op = client.reasoners.resume(reasoner_type=t_norm, reasoner_name=name)
            assert op is not None
            console.print(f"[green]resume: OK[/green] (operation_id={op.id})")
            console.print("")
            console.print("Reasoner resume has started and will continue in the background.")
            console.print(f"Check status: [green]rai reasoners:get --type {t_label} --name {name}[/green]")
            console.print(tip("use [cyan]--wait[/cyan] flag to block until reasoner is in READY status."))

            r = None
            with WaitSpinner(f"Fetching {t_label} reasoner '{name}'..."):
                r = client.reasoners.get(reasoner_type=t_norm, reasoner_name=name)
            show_reasoner(r or Reasoner(name=name, type=t_norm, state="PENDING"))

        # If both provided, resume directly (still validates/normalizes type).
        if reasoner_type and reasoner_name:
            t_norm, t_label = ensure_reasoner_type(reasoner_type, interactive=interactive, console=console)
            with _cli_connect_sync(config) as client:
                _resume_one(client=client, t_norm=t_norm, t_label=t_label, name=reasoner_name)
            return

        # If the user provided exactly one of (type, name), prompt for the other.
        if reasoner_name and not reasoner_type:
            t_norm, t_label = ensure_reasoner_type(None, interactive=interactive, console=console)
            name = ensure_reasoner_name(reasoner_name, interactive=interactive, console=console)
            with _cli_connect_sync(config) as client:
                _resume_one(client=client, t_norm=t_norm, t_label=t_label, name=name)
            return

        with _cli_connect_sync(config) as client:
            picked = select_reasoner(
                client.reasoners,
                reasoner_type=reasoner_type,
                reasoner_name=reasoner_name,
                state="SUSPENDED",
                interactive=interactive,
                console=console,
                prompt="Select a reasoner to resume:",
            )
            if picked is None:
                return
            r_type, r_name = picked
            t_norm, t_label = ensure_reasoner_type(r_type, interactive=interactive, console=console)
            _resume_one(client=client, t_norm=t_norm, t_label=t_label, name=r_name)
    except Exception as e:
        _cli_fail(
            name="reasoners:resume",
            message="Failed to resume reasoner.",
            error=e,
            click_message="Reasoners resume failed.",
            params={"reasoner_type": reasoner_type, "reasoner_name": reasoner_name},
        )


@cli.command(name="reasoners:list")
@click.option(
    "--type",
    "reasoner_type",
    required=False,
    default=None,
    help="Filter by reasoner type (Logic/Prescriptive/Predictive) (optional).",
)
@click.option("--name", "reasoner_name", required=False, default=None, help="Filter by reasoner name (optional).")
@click.option("--size", "reasoner_size", required=False, default=None, help="Filter by reasoner size (optional).")
@click.option("--state", required=False, default=None, help="Filter by reasoner state (optional).")
@click.option("--created-by", "created_by", required=False, default=None, help="Filter by creator username (optional).")
@include_in_docs
def reasoners_list(
    reasoner_type: str | None,
    reasoner_name: str | None,
    reasoner_size: str | None,
    state: str | None,
    created_by: str | None,
):
    """List reasoners with optional filters."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_reasoners
    from relationalai.tools.cli.flows import ensure_reasoner_type

    config = _load_config()
    try:
        interactive = _is_interactive()

        t_norm: str | None = None
        t_label: str | None = None
        raw_type = (reasoner_type or "").strip()
        if raw_type:
            t_norm, t_label = ensure_reasoner_type(raw_type, interactive=interactive, console=console)

        with _cli_connect_sync(config) as client:
            items = None
            msg = "Fetching reasoners..." if not t_label else f"Fetching {t_label.lower()} reasoners..."
            with WaitSpinner(msg):
                items = client.reasoners.list(
                    state=state,
                    reasoner_name=reasoner_name,
                    reasoner_type=t_norm,
                    reasoner_size=reasoner_size,
                    created_by=created_by,
                )
            assert items is not None

        if not items:
            console.print("[yellow]No reasoners found.[/yellow]")
            return

        show_reasoners(items)
    except Exception as e:
        _cli_fail(
            name="reasoners:list",
            message="Failed to list reasoners.",
            error=e,
            click_message="Reasoners list failed.",
        )


@cli.command(name="reasoners:get")
@click.option(
    "--type",
    "reasoner_type",
    required=False,
    default=None,
    help="Reasoner type (Logic/Prescriptive/Predictive) (optional).",
)
@click.option("--name", "reasoner_name", required=False, default=None, help="Reasoner name (optional).")
@include_in_docs
def reasoners_get(reasoner_type: str | None, reasoner_name: str | None):
    """Get a reasoner by type and name."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_reasoner
    from relationalai.tools.cli.flows import ensure_reasoner_name, ensure_reasoner_type

    config = _load_config()
    try:
        interactive = _is_interactive()
        t_norm, t_label = ensure_reasoner_type(reasoner_type, interactive=interactive, console=console)
        name = ensure_reasoner_name(reasoner_name, interactive=interactive, console=console)

        with _cli_connect_sync(config) as client:
            r = None
            with WaitSpinner(f"Fetching {t_label} reasoner '{name}'..."):
                r = client.reasoners.get(reasoner_type=t_norm, reasoner_name=name)

        if r is None:
            console.print("[yellow]Reasoner not found.[/yellow]")
            return

        show_reasoner(r)
    except Exception as e:
        _cli_fail(
            name="reasoners:get",
            message="Failed to get reasoner.",
            error=e,
            click_message="Reasoners get failed.",
            params={"reasoner_type": reasoner_type, "reasoner_name": reasoner_name},
        )


@cli.command(name="jobs:list")
@click.option(
    "--type",
    "job_type",
    required=False,
    default=None,
    help="Filter by job type (Logic/Prescriptive/Predictive).",
)
@click.option("--id", "job_id", required=False, default=None, help="Filter by job id (optional).")
@click.option("--state", required=False, default=None, help="Filter by job state (optional).")
@click.option("--name", required=False, default=None, help="Filter by reasoner name (optional).")
@click.option("--created-by", "created_by", required=False, default=None, help="Filter by creator username (optional).")
@click.option("--only-active", is_flag=True, help="Only active jobs.")
@click.option("--limit", type=int, default=None, help="Optional limit.")
@click.option(
    "--include",
    "includes",
    multiple=True,
    type=click.Choice(["details"], case_sensitive=False),
    help="Include optional fields in list output (currently only `details`).",
)
@include_in_docs
def jobs_list(
    job_type: str | None,
    job_id: str | None,
    state: str | None,
    name: str | None,
    created_by: str | None,
    only_active: bool,
    limit: int | None,
    includes: tuple[str, ...],
):
    """List jobs with optional filters."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_jobs
    from relationalai.tools.cli.flows import ensure_job_type

    config = _load_config()
    try:
        interactive = _is_interactive()
        t_public: str | None = None
        t_label: str | None = None
        if job_type is not None and str(job_type).strip():
            t_public, t_label = ensure_job_type(job_type, interactive=interactive, console=console)

        include = _job_include_from_flags(includes)
        include_details = bool(include.get("details")) if include is not None else False

        with _cli_connect_sync(config) as client:
            items: list[Any] = []
            msg = "Fetching jobs..." if t_label is None else f"Fetching {t_label} jobs..."
            with WaitSpinner(msg):
                items = client.jobs.list(
                    t_public,
                    job_id=job_id,
                    state=state,
                    name=name,
                    created_by=created_by,
                    only_active=bool(only_active),
                    all_users=False,
                    limit=limit,
                    include=include,
                )

        if not items:
            console.print("[yellow]No jobs found.[/yellow]")
            return

        show_jobs(items, include_details=include_details)
    except Exception as e:
        _cli_fail(
            name="jobs:list",
            message="Failed to list jobs.",
            error=e,
            click_message="Jobs list failed.",
            params={
                "reasoner_type": job_type,
                "job_id": job_id,
                "state": state,
                "name": name,
                "created_by": created_by,
            },
        )


@cli.command(name="jobs:get")
@click.option(
    "--type",
    "job_type",
    required=False,
    default=None,
    help="Job type (Logic/Prescriptive/Predictive).",
)
@click.option("--id", "job_id", required=False, default=None, help="Job id (optional).")
@click.option(
    "--include",
    "includes",
    multiple=True,
    type=click.Choice(["details", "events", "artifacts", "problems", "raw"], case_sensitive=False),
    help="Repeat to include expansions (e.g. --include details --include events).",
)
@include_in_docs
def jobs_get(job_type: str | None, job_id: str | None, includes: tuple[str, ...]):
    """Get a job by type and id."""
    from relationalai.tools.cli.display import print_job_expansions, show_job

    config = _load_config()
    try:
        interactive = _is_interactive()
        include = _job_include_from_flags(includes)

        with _cli_connect_sync(config) as client:
            picked = _ensure_job_identity(
                client.jobs,
                config=config,
                job_type=job_type,
                job_id=job_id,
                interactive=interactive,
                prompt="Select a job:",
            )
            if picked is None:
                return
            rt, jid = picked

            job = None
            job = _jobs_get_with_spinner(client.jobs, str(rt), str(jid), include=include)
            if job is None:
                console.print("[yellow]Job not found.[/yellow]")
                return
            show_job(job)
            print_job_expansions(job)
    except Exception as e:
        _cli_fail(
            name="jobs:get",
            message="Failed to get job.",
            error=e,
            click_message="Jobs get failed.",
            params={"job_type": job_type, "job_id": job_id},
        )


@cli.command(name="jobs:events")
@click.option(
    "--type",
    "job_type",
    required=True,
    help="Job type (Logic/Prescriptive/Predictive).",
)
@click.option("--id", "job_id", required=True, help="Job id.")
@click.option(
    "--continuation-token",
    default="",
    show_default=False,
    help="Continuation token from a previous events call (optional).",
)
@click.option(
    "--stream-name",
    default=None,
    show_default=False,
    help="Events stream name (optional). Defaults to `profiler` for Direct Access Logic jobs and `progress` otherwise.",
)
@include_in_docs
def jobs_events(job_type: str, job_id: str, continuation_token: str, stream_name: str | None):
    """Get events for a job."""
    from relationalai.tools.cli.components import WaitSpinner
    config = _load_config()
    try:
        # Unlike most CLI surfaces, `jobs:events` historically accepts the internal
        # Jobs type discriminators (LOGIC/SOLVER/ML) in addition to public labels.
        from relationalai.tools.cli.job_types import JOB_TYPE_LOGIC, JOB_TYPE_ML, JOB_TYPE_SOLVER, normalize_job_type

        raw = str(job_type or "").strip()
        if raw.upper() in {JOB_TYPE_LOGIC, JOB_TYPE_SOLVER, JOB_TYPE_ML}:
            rt = raw.upper()
        else:
            rt = normalize_job_type(raw)

        # Default stream depends on backend + type:
        # - SQL backend supports only `progress` for Logic events
        # - Direct Access supports `profiler` for Logic events (preferred default)
        stream = (stream_name or "").strip()
        if not stream:
            if config.direct_access and rt == JOB_TYPE_LOGIC:
                stream = "profiler"
            else:
                stream = "progress"

        with _cli_connect_sync(config) as client:
            ev = None
            with WaitSpinner(f"Fetching events for job {job_id}..."):
                ev = client.jobs.get_events(
                    str(rt),
                    str(job_id),
                    continuation_token=str(continuation_token or ""),
                    stream_name=str(stream),
                )
            assert ev is not None
            console.print(
                json.dumps(
                    {"events": getattr(ev, "events", []), "continuation_token": getattr(ev, "continuation_token", None)},
                    indent=2,
                    default=str,
                )
            )
    except Exception as e:
        _cli_fail(
            name="jobs:events",
            message="Failed to get job events.",
            error=e,
            click_message="Jobs events failed.",
            params={"job_type": job_type, "job_id": job_id, "stream_name": stream_name},
        )


@cli.command(name="jobs:wait")
@click.option(
    "--type",
    "job_type",
    required=False,
    default=None,
    help="Job type (Logic/Prescriptive/Predictive).",
)
@click.option("--id", "job_id", required=False, default=None, help="Job id (optional).")
@click.option(
    "--timeout-s",
    type=float,
    default=900,
    show_default=True,
    help="Timeout in seconds while waiting until the job reaches a terminal state.",
)
@click.option(
    "--poll-interval-s",
    type=float,
    default=0.5,
    show_default=True,
    help="Polling interval in seconds while waiting until the job reaches a terminal state.",
)
@include_in_docs
def jobs_wait(job_type: str | None, job_id: str | None, timeout_s: float, poll_interval_s: float):
    """Wait until a job reaches a terminal state."""
    from relationalai.tools.cli.display import show_job

    config = _load_config()
    try:
        interactive = _is_interactive()
        with _cli_connect_sync(config) as client:
            picked = _ensure_job_identity(
                client.jobs,
                config=config,
                job_type=job_type,
                job_id=job_id,
                interactive=interactive,
                prompt="Select a job to wait on:",
            )
            if picked is None:
                return
            rt, jid = picked

            job_result = None
            job_result = _jobs_wait_with_spinner(
                client.jobs,
                str(rt),
                str(jid),
                timeout_s=timeout_s,
                poll_interval_s=poll_interval_s,
            )
            assert job_result is not None
            show_job(job_result)
    except Exception as e:
        _cli_fail(
            name="jobs:wait",
            message="Failed to wait for job.",
            error=e,
            click_message="Jobs wait failed.",
            params={"job_type": job_type, "job_id": job_id},
        )


@cli.command(name="jobs:cancel")
@click.option(
    "--type",
    "job_type",
    required=False,
    default=None,
    help="Job type (Logic/Prescriptive/Predictive).",
)
@click.option("--id", "job_id", required=False, default=None, help="Job id (optional).")
@include_in_docs
def jobs_cancel(job_type: str | None, job_id: str | None):
    """Cancel a job."""
    from relationalai.tools.cli.components import WaitSpinner
    from relationalai.tools.cli.display import show_job

    config = _load_config()
    try:
        interactive = _is_interactive()
        with _cli_connect_sync(config) as client:
            picked = _ensure_job_identity(
                client.jobs,
                config=config,
                job_type=job_type,
                job_id=job_id,
                interactive=interactive,
                prompt="Select a job to cancel:",
                only_active=True,
            )
            if picked is None:
                return
            rt, jid = picked

            with WaitSpinner(f"Cancelling job {jid}..."):
                client.jobs.cancel(str(rt), str(jid))
            job = None
            job = _jobs_get_with_spinner(client.jobs, str(rt), str(jid), include=None)
            if job is not None:
                show_job(job)
    except Exception as e:
        _cli_fail(
            name="jobs:cancel",
            message="Failed to cancel job.",
            error=e,
            click_message="Jobs cancel failed.",
            params={"job_type": job_type, "job_id": job_id},
        )


@cli.command(name="jobs:create")
@click.option(
    "--type",
    "job_type",
    required=False,
    default=None,
    help="Job type (Logic/Prescriptive/Predictive).",
)
@click.option(
    "--reasoner-name",
    "--name",
    "reasoner_name",
    required=False,
    default=None,
    help="Reasoner name to run on (optional).",
)
@click.option("--database", default=None, help="Database (required for Logic when composing a payload).")
@click.option("--raw-code", default=None, help="Raw code for payload construction.")
@click.option("--raw-code-file", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None)
@click.option("--inputs-json", default=None, help="Inputs JSON object (default: {}).")
@click.option("--headers-json", default=None, help="Headers JSON object (optional).")
@click.option("--language", default="rel", show_default=True)
@click.option("--payload-json", default="{}", show_default=True, help="JSON payload override/extension.")
@click.option("--timeout-mins", type=int, default=None)
@click.option(
    "--timeout-s",
    type=float,
    default=900,
    show_default=True,
    help="Timeout in seconds while waiting until the job reaches a terminal state (used with --wait).",
)
@click.option(
    "--poll-interval-s",
    type=float,
    default=0.5,
    show_default=True,
    help="Polling interval in seconds while waiting until the job reaches a terminal state.",
)
@click.option("--bypass-index", is_flag=True)
@click.option("--gi-setup-skipped", is_flag=True)
@click.option("--readonly/--readwrite", default=True, show_default=True)
@click.option(
    "--wait",
    is_flag=True,
    help="Wait until the job reaches a terminal state before returning.",
)
@include_in_docs
def jobs_create(
    job_type: str | None,
    reasoner_name: str | None,
    database: str | None,
    raw_code: str | None,
    raw_code_file: Path | None,
    inputs_json: str | None,
    headers_json: str | None,
    language: str,
    payload_json: str,
    timeout_mins: int | None,
    timeout_s: float,
    poll_interval_s: float,
    bypass_index: bool,
    gi_setup_skipped: bool,
    readonly: bool,
    wait: bool,
):
    """Create a job."""
    from relationalai.tools.cli.components import WaitSpinner, text_prompt
    from relationalai.tools.cli.display import show_job
    from relationalai.tools.cli.flows import ensure_job_type, ensure_reasoner_name

    config = _load_config()
    t_public: str | None = None
    reasoner_name_norm: str | None = None
    try:
        from relationalai.services.jobs.util import compose_payload

        interactive = _is_interactive()

        t_public, t_label = ensure_job_type(job_type, interactive=interactive, console=console)
        reasoner_name_norm = ensure_reasoner_name(reasoner_name, interactive=interactive, console=console)

        # Load raw code from file if provided.
        if raw_code_file is not None:
            raw_code = raw_code_file.read_text(encoding="utf-8")

        # Parse explicit JSON payload (override/extend).
        try:
            payload_override = json.loads(payload_json or "{}")
        except json.JSONDecodeError as e:
            raise click.ClickException(f"--payload-json must be valid JSON. {e}") from e
        if not isinstance(payload_override, dict):
            raise click.ClickException("--payload-json must decode to a JSON object (dict).")

        inputs: dict[str, Any] | None = None
        if inputs_json is not None:
            try:
                v = json.loads(inputs_json or "{}")
            except json.JSONDecodeError as e:
                raise click.ClickException(f"--inputs-json must be valid JSON. {e}") from e
            if not isinstance(v, dict):
                raise click.ClickException("--inputs-json must decode to a JSON object (dict).")
            inputs = v

        headers: dict[str, Any] | None = None
        if headers_json is not None:
            try:
                v = json.loads(headers_json or "{}")
            except json.JSONDecodeError as e:
                raise click.ClickException(f"--headers-json must be valid JSON. {e}") from e
            if not isinstance(v, dict):
                raise click.ClickException("--headers-json must decode to a JSON object (dict).")
            headers = v

        if raw_code is None and payload_override == {}:
            if interactive:
                raw_code = text_prompt("Raw code:", default=None)
                console.print()
            else:
                raise click.ClickException(
                    "No payload provided. Pass `--payload-json '{...}'` or provide `--raw-code` (and `--database` for Logic)."
                )

        if str(t_public).strip().lower() == "logic" and raw_code is not None and not database:
            if interactive:
                database = text_prompt("Database:", default=None)
                console.print()
            else:
                raise click.ClickException("--database is required for Logic when using --raw-code/--raw-code-file.")

        composed: dict[str, Any] = {}
        if raw_code is not None:
            composed = compose_payload(
                reasoner_type=t_public,
                database=database,
                raw_code=raw_code,
                inputs=inputs,
                readonly=bool(readonly),
                language=language,
                headers=headers,
                timeout_mins=timeout_mins,
                bypass_index=bool(bypass_index),
                gi_setup_skipped=bool(gi_setup_skipped),
            )

        payload = dict(composed)
        payload.update(payload_override)

        with _cli_connect_sync(config) as client:
            op_id = ""
            with WaitSpinner(f"Starting {t_label} job on reasoner '{reasoner_name_norm}'..."):
                op = client.jobs.create(t_public, reasoner_name_norm, payload, timeout_mins=timeout_mins)
                op_id = op.id
            if not op_id:
                raise click.ClickException("Job create did not return an id.")

            if not wait:
                job = _jobs_get_with_spinner(client.jobs, str(t_public), str(op_id), include=None)
                if job is None:
                    console.print(f"[green]create: OK[/green] (id={op_id})")
                    _print_job_background_instructions(job_type_label=t_label, job_id=op_id, created=True)
                    return
                show_job(job)
                _print_job_background_instructions(job_type_label=t_label, job_id=op_id, created=True)
                return

            job_result = None
            job_result = _jobs_wait_with_spinner(
                client.jobs,
                str(t_public),
                str(op_id),
                timeout_s=float(timeout_s),
                poll_interval_s=float(poll_interval_s),
            )

            assert job_result is not None
            show_job(job_result)
            state = str(getattr(job_result, "state", "") or "").upper()
            success_states = {"COMPLETED", "SUCCEEDED"}
            if state and state not in success_states:
                raise click.ClickException(f"Job finished in state {state}.")
    except Exception as e:
        _cli_fail(
            name="jobs:create",
            message="Failed to create job.",
            error=e,
            click_message="Jobs create failed.",
            params={"reasoner_type": t_public, "reasoner_name": reasoner_name_norm},
        )


@cli.group(hidden=True)
def docs():
    """Generate and serve documentation site."""
    pass


docs = cast(click.Group, docs)

@docs.command()
@click.option(
    "--output",
    "-o",
    default=None,
    help="Output directory for generated documentation (defaults to docs/dist)",
)
def generate(output: str | None):
    """Generate static documentation site from models."""
    from .docs import generate_docs

    try:
        generate_docs(output_dir=output)
    except click.ClickException as e:
        _emit_cli_error(name="docs", message="Failed to generate documentation.", details=e.message)
        sys.exit(1)
    except Exception as e:
        _emit_cli_error(name="docs", message="Failed to generate documentation.", details=str(e) or repr(e))
        sys.exit(1)


@docs.command()
@click.option(
    "--port",
    "-p",
    default=8000,
    type=int,
    help="Port to serve documentation on (default: 8000)",
)
@click.option(
    "--host",
    default="127.0.0.1",
    help="Host to bind to (default: 127.0.0.1)",
)
@click.option(
    "--build-dir",
    default=None,
    help="Directory containing built documentation (defaults to docs/dist)",
)
@click.option(
    "--no-open",
    is_flag=True,
    help="Don't open browser automatically",
)
def serve(port: int, host: str, build_dir: str | None, no_open: bool):
    """Serve generated documentation site."""
    from .docs import find_docs_dir, serve_docs

    # Determine build directory
    if build_dir is None:
        docs_dir = find_docs_dir()
        build_dir = str(docs_dir / "dist")

    try:
        serve_docs(
            build_dir=build_dir,
            host=host,
            port=port,
            open_browser=not no_open,
        )
    except click.ClickException as e:
        _emit_cli_error(name="docs", message="Failed to start documentation server.", details=e.message)
        sys.exit(1)
    except Exception as e:
        _emit_cli_error(name="docs", message="Failed to start documentation server.", details=str(e) or repr(e))
        sys.exit(1)


@docs.command()
@click.option(
    "--port",
    "-p",
    default=5173,
    type=int,
    help="Port to serve development server on (default: 5173)",
)
@click.option(
    "--host",
    default="127.0.0.1",
    help="Host to bind to (default: 127.0.0.1)",
)
@click.option(
    "--no-open",
    is_flag=True,
    help="Don't open browser automatically",
)
def dev(port: int, host: str, no_open: bool):
    """Start development server with hot module replacement (HMR).

    This command runs Vite's dev server which provides:
    - Hot module replacement - changes reflect instantly
    - Fast refresh - no need to rebuild on every change
    - Source maps for debugging

    Use this for development instead of 'rai docs generate' + 'rai docs serve'.
    """
    from .docs import dev_docs

    try:
        dev_docs(
            host=host,
            port=port,
            open_browser=not no_open,
        )
    except click.ClickException as e:
        _emit_cli_error(name="docs", message="Failed to start documentation dev server.", details=e.message)
        sys.exit(1)
    except Exception as e:
        _emit_cli_error(name="docs", message="Failed to start documentation dev server.", details=str(e) or repr(e))
        sys.exit(1)


@docs.command(name="clean")
def clean_docs():
    """Remove auto generated files and directories."""
    from .docs import cleanup_docs

    try:
        cleanup_docs()
    except click.ClickException as e:
        _emit_cli_error(name="docs", message="Failed to clean documentation artifacts.", details=e.message)
        sys.exit(1)
    except Exception as e:
        _emit_cli_error(name="docs", message="Failed to clean documentation artifacts.", details=str(e) or repr(e))
        sys.exit(1)


if __name__ == "__main__":
    cli()  # type: ignore[call-arg]
