# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""CLI script for running CXAS SCRAPI evaluations."""

from __future__ import annotations

import argparse
import contextlib
import datetime
import json
import logging
import os
import subprocess
import sys
import time
import uuid
from typing import TYPE_CHECKING

from cxas_scrapi.cli.insights_cli import populate_insights_parser
from cxas_scrapi.cli.resources_cli import (
    register as register_resources_subparsers,
)
from cxas_scrapi.cli.trace_cli import register as register_trace_subparser
from cxas_scrapi.utils.eval_utils import (
    COMBINED_REPORT_FILENAME,
    COMBINED_REPORT_JSON_FILENAME,
)

DEFAULT_MODEL = "gemini-3.1-flash-live"

if TYPE_CHECKING:
    import pandas as pd

    from cxas_scrapi.cli.app import (
        app_branch,
        app_create,
        app_delete,
        app_init,
        app_lint,
        app_pull,
        app_push,
        apps_get,
        apps_list,
    )
    from cxas_scrapi.cli.create_local import handle_local_create
    from cxas_scrapi.cli.llm_lint import llm_lint
    from cxas_scrapi.cli.versions_cli import (
        app_versions_compare,
        app_versions_list,
    )
    from cxas_scrapi.core.github import init_github_action
    from cxas_scrapi.utils.eval_utils import EvalUtils
else:
    from cxas_scrapi.cli.utils import LazyCallable

    app_branch = LazyCallable("cxas_scrapi.cli.app", "app_branch")
    app_create = LazyCallable("cxas_scrapi.cli.app", "app_create")
    app_delete = LazyCallable("cxas_scrapi.cli.app", "app_delete")
    app_init = LazyCallable("cxas_scrapi.cli.app", "app_init")
    app_lint = LazyCallable("cxas_scrapi.cli.app", "app_lint")
    app_pull = LazyCallable("cxas_scrapi.cli.app", "app_pull")
    app_push = LazyCallable("cxas_scrapi.cli.app", "app_push")
    apps_get = LazyCallable("cxas_scrapi.cli.app", "apps_get")
    apps_list = LazyCallable("cxas_scrapi.cli.app", "apps_list")
    handle_local_create = LazyCallable(
        "cxas_scrapi.cli.create_local", "handle_local_create"
    )
    llm_lint = LazyCallable("cxas_scrapi.cli.llm_lint", "llm_lint")
    app_versions_list = LazyCallable(
        "cxas_scrapi.cli.versions_cli", "app_versions_list"
    )
    app_versions_compare = LazyCallable(
        "cxas_scrapi.cli.versions_cli", "app_versions_compare"
    )
    init_github_action = LazyCallable(
        "cxas_scrapi.core.github", "init_github_action"
    )

logger = logging.getLogger(__name__)


def export_eval(args: argparse.Namespace) -> None:
    """Handles the 'export' command."""
    from cxas_scrapi.core.evaluations import Evaluations, ExportFormat

    print(f"Exporting evaluation: {args.evaluation_id}")
    # Use app_name to init client. Eval ID might be full resource name.
    eval_client = Evaluations(app_name=args.app_name)

    try:
        format_enum = (
            ExportFormat(args.format.lower())
            if args.format
            else ExportFormat.YAML
        )
        exported_eval = eval_client.export_evaluation(
            args.evaluation_id,
            output_format=format_enum,
            output_path=args.output,
        )
        if args.output:
            print(f"Evaluation exported to {args.output}")
        else:
            print(exported_eval)

    except Exception as e:
        print(f"Failed to export evaluation: {e}")
        sys.exit(1)


def run_migration_dashboard(args: argparse.Namespace) -> None:
    """Handles the unified 'cxas migrate dfcx' command, routing to
    non-interactive run / optimize stages or the interactive TUI dashboard.
    """
    from cxas_scrapi.cli.migration_cli import (
        run_end_to_end,
        run_resume,
        run_stage_1,
        run_stage_2,
        run_stage_3,
    )

    if getattr(args, "run", False):
        # Validate E2E requirements
        if not (
            getattr(args, "source_agent_id", None)
            or getattr(args, "source_zip", None)
        ):
            print(
                "Error: You must provide either --source-agent-id or "
                "--source-zip for non-interactive --run."
            )
            sys.exit(1)
        if not getattr(args, "project_id", None):
            print(
                "Error: Target --project-id is required for "
                "non-interactive --run."
            )
            sys.exit(1)
        if not getattr(args, "target_name", None):
            print(
                "Error: Target --target-name is required for "
                "non-interactive --run."
            )
            sys.exit(1)

        run_end_to_end(args)

    elif getattr(args, "optimize", False):
        # Validate stage requirements
        if not getattr(args, "stage", None):
            print(
                "Error: You must specify a target --stage (1, 2, 3, "
                "or resume) when using --optimize."
            )
            sys.exit(1)

        # Set default flags
        args.yes = True  # optimize stage is non-interactive by default

        if args.stage == "1":
            if not getattr(args, "version_label", None):
                args.version_label = "0.0.3"
            run_stage_1(args)
        elif args.stage == "2":
            if not getattr(args, "version_label", None):
                args.version_label = "0.0.4"
            run_stage_2(args)
        elif args.stage == "3":
            if not getattr(args, "version_label", None):
                args.version_label = "0.0.5"
            run_stage_3(args)
        elif args.stage == "resume":
            args.yes = False  # resume is interactive picker
            run_resume(args)

    else:
        # Default: Interactive TUI Dashboard Mode
        from cxas_scrapi.cli.migration_cli import MigrationCLI  # noqa: PLC0415
        from cxas_scrapi.migration.dfcx_exporter import ConversationalAgentsAPI

        dashboard = MigrationCLI()
        cx_api = ConversationalAgentsAPI()
        dashboard.run(default_agent_name=args.default_agent_name, cx_api=cx_api)


def push_eval(args: argparse.Namespace) -> None:
    """Handles the 'push-eval' command."""
    from cxas_scrapi.core.evaluations import Evaluations
    from cxas_scrapi.utils.eval_utils import EvalUtils

    print(f"Pushing evaluation(s) from {args.file} to App: {args.app_name}")

    eval_client = Evaluations(app_name=args.app_name)
    eval_utils = EvalUtils(app_name=args.app_name)

    try:
        evals = eval_utils.load_golden_evals_from_yaml(args.file)
        if not evals:
            print(f"No valid evaluations found in '{args.file}'.")
            sys.exit(1)

        print(f"Parsed {len(evals)} evaluation(s). Syncing...")
        for eval_dict in evals:
            res = eval_client.update_evaluation(
                evaluation=eval_dict, app_name=args.app_name
            )
            print(f"Pushed: '{res.display_name}' ({res.name})")

        print("\nPush complete.")

    except Exception as e:
        print(f"Failed to push evaluation(s): {e}")
        sys.exit(1)


def wait_for_evaluation_completion(
    eval_utils: EvalUtils,
    old_result_ids: list[str],
    app_name: str,
    expected_count: int = 1,
    timeout_seconds: int = 600,
) -> dict[str, pd.DataFrame]:
    """Waits for all new evaluation results to appear."""
    import pandas as pd

    print(f"Waiting for {expected_count} evaluation(s) to complete...")
    start_time = time.time()
    while time.time() - start_time < timeout_seconds:
        # Fetch current evaluation results
        try:
            df_dict = eval_utils.evals_to_dataframe()
            df_current = df_dict.get("summary", pd.DataFrame())
            if df_current.empty:
                time.sleep(5)
                continue

            # Find new runs
            current_result_ids = set(df_current["eval_result_id"].unique())
            new_ids = current_result_ids - old_result_ids

            if new_ids and len(new_ids) >= expected_count:
                # Wait for ALL new runs to complete
                all_completed = True
                completed_results = []
                for run_id in new_ids:
                    df_new = df_current[df_current["eval_result_id"] == run_id]
                    exec_state = (
                        df_new["execution_state"].iloc[0]
                        if not df_new.empty
                        and "execution_state" in df_new.columns
                        else "COMPLETED"
                    )

                    if exec_state not in ("COMPLETED", "ERROR"):
                        all_completed = False
                        break

                    # Fetch trace
                    raw = eval_utils.eval_client.get_evaluation_result(run_id)
                    completed_results.append(raw)

                if all_completed:
                    print(f"All {len(new_ids)} evaluations completed.")
                    return eval_utils.evals_to_dataframe(
                        results=completed_results
                    )

        except Exception as e:
            print(f"Error checking evaluation status: {e}")

        time.sleep(5)

    print("Timeout waiting for evaluation to complete.")
    sys.exit(1)


def filter_metrics_and_assess(
    df_dict_new_run: dict[str, pd.DataFrame],
    filter_auto_metrics: bool,
) -> bool:
    """Assesses the evaluation run and returns True if passed,
    False otherwise."""
    import pandas as pd

    passed = True

    df_new_run = df_dict_new_run.get("summary", pd.DataFrame())
    df_expectations = df_dict_new_run.get("expectations", pd.DataFrame())

    # Standard assessment: check standard status first
    # This might encompass semantic and hallucination metrics

    num_passed = 0
    num_failed = 0
    num_error = 0
    if not df_new_run.empty:
        for _, row in df_new_run.iterrows():
            eval_stat = str(row.get("evaluation_status", "")).upper()
            exec_stat = str(row.get("execution_state", "")).upper()

            if exec_stat in ("ERROR", "ERRORED") or eval_stat in (
                "ERROR",
                "ERRORED",
            ):
                num_error += 1
            elif eval_stat in ("PASS", "PASSED", "✅ PASSED"):
                num_passed += 1
            else:
                num_failed += 1

    overall_status = (
        "PASS"
        if num_failed == 0 and num_error == 0 and num_passed > 0
        else "FAIL"
        if (num_failed > 0 or num_error > 0)
        else "UNKNOWN"
    )

    print(f"\n--- Evaluation Status: {overall_status} ---")
    print(f"Passed: {num_passed}")
    print(f"Failed: {num_failed}")
    print(f"Errored: {num_error}")

    if filter_auto_metrics:
        print(
            "\n[Targeted Assessment] Filtering out automated LLM metrics "
            "(semantic similarity, hallucination)."
        )
        print("Focusing strictly on custom expectations and tool invocation.")

        if (
            not df_expectations.empty
            and "record_type" in df_expectations.columns
        ):
            expectation_rows = df_expectations[
                df_expectations["record_type"] == "summary_expectation"
            ]
        else:
            expectation_rows = pd.DataFrame()

        if not expectation_rows.empty:
            failed_expectations = expectation_rows[
                expectation_rows["not_met_count"] > 0
            ]
            if not failed_expectations.empty:
                print(
                    f"FAILED: {len(failed_expectations)} custom expectations "
                    "not met."
                )
                for _, row in failed_expectations.iterrows():
                    print(
                        f"  - Expectation: {row['expectation']} "
                        f"(Met: {row['met_count']}, "
                        f"Not Met: {row['not_met_count']})"
                    )
                passed = False
            else:
                print(
                    f"PASSED: All {len(expectation_rows)} custom expectations "
                    "met."
                )
        else:
            print("WARNING: No custom expectations found in this evaluation.")
            # Fallback: check basic tool execution result limit

    # Strict overall pass/fail based on the server constraints
    elif overall_status != "PASS":
        passed = False

    return passed


def run_eval(args: argparse.Namespace) -> None:
    """Handles the 'run' command."""
    import pandas as pd

    from cxas_scrapi.core.evaluations import Evaluations
    from cxas_scrapi.utils.eval_utils import EvalUtils

    print(f"Triggering evaluation for App: {args.app_name}")
    eval_client = Evaluations(app_name=args.app_name)
    eval_utils = EvalUtils(app_name=args.app_name)

    # Determine which evaluations to run
    evaluations_to_run = []
    if args.evaluation_id:
        evaluations_to_run.append(args.evaluation_id)
    else:
        # Require prefix or tags if no specific ID is given
        if not args.display_name_prefix and not args.tags:
            print(
                "Error: You must provide either --evaluation-id, "
                "--display-name-prefix, or --tags to "
                "specify which tests to run."
            )
            sys.exit(1)

        if args.display_name_prefix:
            print(
                "Fetching tests matching prefix: "
                f"'{args.display_name_prefix}'..."
            )
        elif args.tags:
            print(f"Fetching tests matching tags: {args.tags}...")
        all_evals = eval_client.list_evaluations(app_name=args.app_name)

        for eval_obj in all_evals:
            match = False

            if args.display_name_prefix and eval_obj.display_name.startswith(
                args.display_name_prefix
            ):
                match = True

            # Assuming tags are accessible as a
            # list/repeated field on the Evaluation
            # object
            if args.tags and hasattr(eval_obj, "tags"):  # noqa: SIM102
                # intersection of CLI tags and agent tags
                if any(t in eval_obj.tags for t in args.tags):
                    match = True

            if match:
                evaluations_to_run.append(eval_obj.name)

        if not evaluations_to_run:
            print(
                "No matching tests found for the "
                "given prefix or tags. Aborting run."
            )
            sys.exit(0)

        print(f"Found {len(evaluations_to_run)} matching test(s) to run.")

    # Determine which evaluations to run
    evaluations_to_run = []
    if args.evaluation_id:
        evaluations_to_run.append(args.evaluation_id)
    else:
        # Require prefix or tags if no specific ID is given
        if not args.display_name_prefix and not args.tags:
            print(
                "Error: You must provide either --evaluation-id, "
                "--display-name-prefix, or --tags to "
                "specify which tests to run."
            )
            sys.exit(1)

        if args.display_name_prefix:
            print(
                "Fetching tests matching prefix: "
                f"'{args.display_name_prefix}'..."
            )
        elif args.tags:
            print(f"Fetching tests matching tags: {args.tags}...")
        all_evals = eval_client.list_evaluations(app_name=args.app_name)

        for eval_obj in all_evals:
            match = False

            if args.display_name_prefix and eval_obj.display_name.startswith(
                args.display_name_prefix
            ):
                match = True

            # Assuming tags are accessible as a
            # list/repeated field on the Evaluation
            # object
            if args.tags and hasattr(eval_obj, "tags"):  # noqa: SIM102
                # intersection of CLI tags and agent tags
                if any(t in eval_obj.tags for t in args.tags):
                    match = True

            if match:
                evaluations_to_run.append(eval_obj.name)

        if not evaluations_to_run:
            print(
                "No matching tests found for the "
                "given prefix or tags. Aborting run."
            )
            sys.exit(0)

        print(f"Found {len(evaluations_to_run)} matching test(s) to run.")

    try:
        # Step 1: Capture existing evaluation runs to diff against later
        df_initial = eval_utils.evals_to_dataframe().get(
            "summary", pd.DataFrame()
        )
        old_result_ids = set()
        if not df_initial.empty and "eval_result_id" in df_initial.columns:
            old_result_ids = set(df_initial["eval_result_id"].unique())

        # Step 2: Trigger evaluation
        eval_client.run_evaluation(
            evaluations=evaluations_to_run,
            app_name=args.app_name,
            modality=args.modality,
            golden_run_method=args.golden_run_method,
        )
        print("Evaluation triggered successfully based on CLI call.")

        # Step 3: Wait and backoff on pending evaluations.
        if args.wait:
            df_new_run = wait_for_evaluation_completion(
                eval_utils,
                old_result_ids,
                args.app_name,
                expected_count=len(evaluations_to_run),
            )
            pass_status = filter_metrics_and_assess(
                df_new_run, args.filter_auto_metrics
            )

            if pass_status:
                print("\nFINAL RESULT: PASS")
                sys.exit(0)
            else:
                df_failures = df_new_run.get("failures", pd.DataFrame())
                if not df_failures.empty:
                    print("\n--- Failure Details ---")
                    grouped = df_failures.groupby("display_name", sort=False)
                    for disp, group_df in grouped:
                        is_err = any(
                            row.get("failure_type") == "System Engine Error"
                            for _, row in group_df.iterrows()
                        )
                        title_str = "Errored" if is_err else "Failed"
                        print(f"\n{disp} {title_str}")

                        sys_errors = group_df[
                            group_df["failure_type"] == "System Engine Error"
                        ]
                        normal_fails = group_df[
                            group_df["failure_type"] != "System Engine Error"
                        ]

                        for _, row in sys_errors.iterrows():
                            print(f"- {row.get('actual')}\n")

                        for _, row in normal_fails.iterrows():
                            idx = row.get("turn_index")
                            tba = f" (Turn {idx})" if pd.notnull(idx) else ""

                            print(f"- Type    : {row.get('failure_type')}{tba}")
                            print(f"- Expected: {row.get('expected')}")
                            print(f"- Actual  : {row.get('actual')}")

                            score = row.get("score")
                            if pd.notnull(score):
                                print(f"- Score   : {score}")
                            print()

                print("\nFINAL RESULT: FAIL")
                sys.exit(1)
    except Exception as e:
        print(f"Failed to run evaluation: {e}")
        sys.exit(1)


def combined_evals_report_cmd(args: argparse.Namespace) -> None:
    """Handles the 'evals report' command."""
    from cxas_scrapi.utils.reporting import (  # noqa: PLC0415
        generate_combined_report_from_dir,
    )

    output_dir = args.output_dir
    timestamp = None
    if getattr(args, "timestamped", False):
        timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")

    output_path = args.gcs_path or args.output

    include_list = args.include.split(",") if args.include else []
    filter_files_list = (
        args.filter_files.split(",")
        if getattr(args, "filter_files", None)
        else []
    )
    filter_tags_list = (
        args.filter_tags.split(",")
        if getattr(args, "filter_tags", None)
        else []
    )
    filter_names_list = (
        args.filter_names.split(",")
        if getattr(args, "filter_names", None)
        else []
    )

    if getattr(args, "input_dir", None):
        if args.tool_test_file == "evals/tool_tests/":
            args.tool_test_file = os.path.join(args.input_dir, "tool_tests/")
        if args.goldens_dir == "evals/goldens/":
            args.goldens_dir = os.path.join(args.input_dir, "goldens/")
        if args.simulation_dir == "evals/simulations/":
            args.simulation_dir = os.path.join(args.input_dir, "simulations/")

    sim_parallel = getattr(args, "sim_parallel", 5)
    golden_timeout = getattr(args, "golden_timeout", 600)

    progress_callback = None
    if getattr(args, "json_progress", False):

        def progress_callback(stage: str, current: int, total: int) -> None:
            import json
            import sys

            sys.stderr.write(
                json.dumps(
                    {
                        "progress": {
                            "stage": stage,
                            "current": current,
                            "total": total,
                        }
                    }
                )
                + "\n"
            )
            sys.stderr.flush()

    actual_output_path = generate_combined_report_from_dir(
        output_dir=output_dir,
        golden_run=args.golden_run,
        app_name=args.app_name,
        output_path=output_path,
        run=args.run,
        app_dir=args.app_dir,
        tool_test_file=args.tool_test_file,
        goldens_dir=args.goldens_dir,
        simulation_dir=args.simulation_dir,
        include=include_list,
        modality=args.modality,
        sim_user_model=args.sim_user_model,
        eval_model=args.eval_model,
        runs=args.runs,
        filter_files=filter_files_list,
        filter_tags=filter_tags_list,
        filter_names=filter_names_list,
        parallel=sim_parallel,
        golden_timeout=golden_timeout,
        bg_noise_file=getattr(args, "bg_noise_file", None),
        burst_noise_files=getattr(args, "burst_noise_files", "").split(",")
        if getattr(args, "burst_noise_files", None)
        else None,
        use_tool_fakes=getattr(args, "use_tool_fakes", False),
        timestamp=timestamp,
        expectations_only=getattr(args, "expectations_only", False),
        deployment_id=getattr(args, "deployment_id", None),
        progress_callback=progress_callback,
        capture_agent_audio=getattr(args, "capture_agent_audio", False),
        single_bidi_stream=getattr(args, "single_bidi_stream", False),
        report_format=getattr(args, "format", "html") or "html",
    )
    print(f"Combined report generated at {actual_output_path}")


def test_tools(args: argparse.Namespace) -> None:
    """Handles the 'test-tools' command."""
    from cxas_scrapi.evals.tool_evals import ToolEvals

    print(
        f"Running tool tests for App: {args.app_name} "
        f"using file: {args.test_file}"
    )
    tool_evals = ToolEvals(app_name=args.app_name)

    try:
        test_cases = tool_evals.load_tool_test_cases_from_file(args.test_file)
        if not test_cases:
            print(f"No valid test cases found in {args.test_file}")
            sys.exit(1)

        results = tool_evals.run_tool_tests(test_cases, debug=args.debug)

        # Check overall status
        failed_count = sum(1 for r in results["status"] if r != "PASSED")

        if failed_count > 0:
            print(f"\nFINAL RESULT: FAIL ({failed_count} tools failed)")
            sys.exit(1)
        else:
            print(f"\nFINAL RESULT: PASS (All {len(results)} tools passed)")
            sys.exit(0)

    except Exception as e:
        print(f"Failed to run tool tests: {e}")
        sys.exit(1)


def test_callbacks(args: argparse.Namespace) -> None:
    """Handles the 'test-callbacks' command."""
    from cxas_scrapi.evals.callback_evals import CallbackEvals

    print(f"Running callback tests in App directory: {args.app_dir}")
    callback_evals = CallbackEvals()

    try:
        results = callback_evals.test_all_callbacks_in_app_dir(
            app_dir=args.app_dir,
            agent_name=args.agent_name,
            callback_type=args.callback_type,
            callback_name=args.callback_name,
            log_file=args.log_file,
            pytest_args=args.pytest_args,
        )
        if results.empty:
            print(f"No valid callback tests found in {args.app_dir}")
            sys.exit(1)

        # Check overall status
        failed_count = sum(1 for r in results["status"] if r != "PASSED")

        if failed_count > 0:
            print(f"\nFINAL RESULT: FAIL ({failed_count} callbacks failed)")
            sys.exit(1)
        else:
            print(f"\nFINAL RESULT: PASS (All {len(results)} callbacks passed)")
            sys.exit(0)

    except Exception as e:
        print(f"Failed to run callback tests: {e}")
        sys.exit(1)


def test_single_callback(args: argparse.Namespace) -> None:
    """Handles the 'test-single-callback' command."""
    from cxas_scrapi.evals.callback_evals import CallbackEvals

    print(
        f"Running single callback test for "
        f"Agent: {args.agent_name}, "
        f"Type: {args.callback_type}"
    )
    callback_evals = CallbackEvals()

    try:
        results = callback_evals.test_single_callback_for_agent(
            app_name=args.app_name,
            agent_name=args.agent_name,
            callback_type=args.callback_type,
            test_file_path=args.test_file_path,
            log_file=args.log_file,
            pytest_args=args.pytest_args,
        )
        if results.empty:
            print(f"No valid callback tests found at {args.test_file_path}")
            sys.exit(1)

        # Check overall status
        failed_count = sum(1 for r in results["status"] if r != "PASSED")

        if failed_count > 0:
            print(f"\nFINAL RESULT: FAIL ({failed_count} callbacks failed)")
            sys.exit(1)
        else:
            print(f"\nFINAL RESULT: PASS (All {len(results)} callbacks passed)")
            sys.exit(0)

    except Exception as e:
        print(f"Failed to run callback tests: {e}")
        sys.exit(1)


def ci_test(args: argparse.Namespace) -> None:
    """Handles the 'ci-test' command."""
    from cxas_scrapi.cli.app import app_push
    from cxas_scrapi.core.apps import Apps
    from cxas_scrapi.core.evaluations import Evaluations

    print("Starting CI Test Lifecycle...")

    if hasattr(args, "display_name") and args.display_name:
        temp_display_name = args.display_name
    else:
        temp_display_name = f"[CI] PR Test {uuid.uuid4().hex[:8]}"

    args.display_name = temp_display_name
    args.app_name = None  # Force create by default

    apps_client = Apps(project_id=args.project_id, location=args.location)

    existing_app = apps_client.get_app_by_display_name(temp_display_name)
    if existing_app:
        print(f"Found existing temp agent: {existing_app.name}. Updating...")
        args.app_name = existing_app.name

    temp_app_name = app_push(args)

    if not temp_app_name:
        print("Failed to get deployed temp app name. CI Test aborting.")
        sys.exit(1)

    try:
        # Run test-tools

        test_file = os.path.join(args.app_dir, "tests", "tool_tests.yaml")
        if os.path.exists(test_file):
            print(f"\\n--- Running Tool Tests on {temp_app_name} ---")
            cmd = [
                "cxas",
                "test-tools",
                "--app-name",
                temp_app_name,
                "--test-file",
                test_file,
            ]
            print(f"Executing: {' '.join(cmd)}")
            res = subprocess.run(cmd, check=False)
            if res.returncode != 0:
                print("Tool tests failed.")
                sys.exit(1)

        # We must evaluate using the API or SDK
        print(f"\\n--- Running Evaluations on {temp_app_name} ---")

        evals_client = Evaluations(app_name=temp_app_name)
        evals_map = evals_client.get_evaluations_map()

        if not evals_map or (
            not evals_map.get("goldens") and not evals_map.get("scenarios")
        ):
            print("No evaluations found in the temp app. Skipping run_eval.")
        else:
            all_eval_ids = list(evals_map.get("goldens", {}).values()) + list(
                evals_map.get("scenarios", {}).values()
            )
            for eval_id in all_eval_ids:
                cmd = [
                    "cxas",
                    "run",
                    "--app-name",
                    temp_app_name,
                    "--evaluation-id",
                    eval_id,
                    "--wait",
                    "--filter-auto-metrics",
                ]
                print(f"Executing: {' '.join(cmd)}")
                res = subprocess.run(cmd, check=False)
                if res.returncode != 0:
                    print(f"Evaluation '{eval_id}' failed.")
                    sys.exit(1)

        print(
            "\\nCI Test Lifecycle Completed Successfully! "
            "Temp agent persists for review."
        )

    except Exception as e:
        print(f"Failed to execute CI Tests: {e}")
        sys.exit(1)


def local_test(args: argparse.Namespace) -> None:
    """Handles the 'local-test' command."""

    agent_dir = os.path.abspath(args.app_dir)
    agent_name = (
        os.path.basename(agent_dir.rstrip(os.sep)).lower().replace(" ", "-")
    )
    tag = f"{agent_name}-local-test"

    print(f"Building Docker image for {agent_name}...")
    # Compilation requires executing from the root agent directory
    build_cmd = ["docker", "build", "-t", tag, agent_dir]
    if subprocess.call(build_cmd) != 0:
        print("Docker build failed.")
        sys.exit(1)

    print("Running tests in Docker container...")

    # Detect ADC
    home = os.path.expanduser("~")
    # Default gcloud location
    adc_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
    if not adc_path:
        adc_path = os.path.join(
            home, ".config/gcloud/application_default_credentials.json"
        )

    docker_cmd = [
        "docker",
        "run",
        "--rm",
        "-v",
        f"{agent_dir}:/workspace",
        "-w",
        "/workspace",
        "-e",
        f"PROJECT_ID={args.project_id}",
        "-e",
        f"LOCATION={args.location}",
    ]

    oauth_token = os.environ.get("CXAS_OAUTH_TOKEN")

    if oauth_token:
        print("Using provided CXAS_OAUTH_TOKEN.")
        docker_cmd.extend(["-e", "CXAS_OAUTH_TOKEN"])
    elif os.path.exists(adc_path):
        print(f"Mounting credentials from {adc_path}")
        docker_cmd.extend(
            [
                "-e",
                "GOOGLE_APPLICATION_CREDENTIALS=/tmp/keys/adc.json",
                "-v",
                f"{adc_path}:/tmp/keys/adc.json:ro",
            ]
        )
    else:
        print(
            "Warning: Application Default Credentials not found. "
            "Authentication may fail."
        )

    display_name = f"[Local] {agent_name}"

    # The command passed to the container
    inner_cmd = [
        tag,
        "ci-test",
        "--app-dir",
        "/workspace",
        "--project-id",
        args.project_id,
        "--location",
        args.location,
        "--display-name",
        display_name,
    ]

    env_file = getattr(args, "env_file", None)
    if env_file:
        inner_cmd.extend(["--env-file", env_file])

    docker_cmd.extend(inner_cmd)

    print(f"Executing: {' '.join(docker_cmd)}")
    sys.exit(subprocess.call(docker_cmd))


def run_session(args: argparse.Namespace) -> None:
    """Handles the 'run-session' command."""
    from cxas_scrapi import Sessions

    if not sys.stdin.isatty():
        msg = "ERROR: 'run-session' requires an interactive terminal."
        print(msg, file=sys.stderr)
        sys.exit(1)

    try:
        session_client = Sessions(args.app_name)
        session_id = session_client.create_session_id()

        while True:
            try:
                user_input = input()
            except (EOFError, KeyboardInterrupt):
                break

            if not user_input.strip():
                continue

            res = session_client.run(
                session_id=session_id,
                text=user_input,
                modality=args.modality,
                use_tool_fakes=args.use_tool_fakes,
            )
            session_client.parse_result(res)
    except Exception as e:
        print(f"Failed to run session: {e}")
        sys.exit(1)


def conversations_list(args: argparse.Namespace) -> None:
    """Lists conversations for an app."""
    from google.protobuf.json_format import MessageToDict

    from cxas_scrapi.core.apps import Apps
    from cxas_scrapi.core.common import Common
    from cxas_scrapi.core.conversation_history import ConversationHistory

    print(f"Listing conversations for App: {args.app_name}")

    # Extract and validate app_name
    app_name = Common._get_app_name(args.app_name)
    if not app_name:
        print(
            "Error: Invalid App Name format. Please use the full resource "
            "name in the format 'projects/.../locations/.../apps/...'"
        )
        sys.exit(1)

    project_id = Common._get_project_id(args.app_name)
    location = Common._get_location(args.app_name)

    apps_client = Apps(project_id=project_id, location=location)
    ch_client = ConversationHistory(
        app_name=args.app_name, creds=apps_client.creds
    )
    conversations = ch_client.list_conversations()

    conversations_dict = []
    for c in conversations:
        try:
            c_dict = MessageToDict(c._pb)
        except AttributeError:
            c_dict = MessageToDict(c)
        conversations_dict.append(c_dict)

    print(json.dumps(conversations_dict, indent=2))


def conversations_get(args: argparse.Namespace) -> None:
    """Gets details of a specific conversation."""
    from google.protobuf.json_format import MessageToDict

    from cxas_scrapi.core.apps import Apps
    from cxas_scrapi.core.common import Common
    from cxas_scrapi.core.conversation_history import ConversationHistory

    print(f"Getting conversation: {args.conversation_resource_name}")

    # Extract and validate app_name
    app_name = Common._get_app_name(args.conversation_resource_name)
    if not app_name:
        print(
            "Error: Invalid Conversation Resource Name format. Please use the "
            "full resource name in the format "
            "'projects/.../locations/.../apps/.../conversations/...'"
        )
        sys.exit(1)

    project_id = Common._get_project_id(args.conversation_resource_name)
    location = Common._get_location(args.conversation_resource_name)

    apps_client = Apps(project_id=project_id, location=location)
    ch_client = ConversationHistory(app_name=app_name, creds=apps_client.creds)
    conv = ch_client.get_conversation(
        conversation_id=args.conversation_resource_name
    )

    try:
        conv_dict = MessageToDict(conv._pb)
    except AttributeError:
        conv_dict = MessageToDict(conv)

    print(json.dumps(conv_dict, indent=2))


def deployments_list(args: argparse.Namespace) -> None:
    """Lists deployments for an app."""
    from google.protobuf.json_format import MessageToDict

    from cxas_scrapi.core.deployments import Deployments

    print(f"Listing deployments for App: {args.app_name}")

    deployments_client = Deployments(app_name=args.app_name)
    deployments = deployments_client.list_deployments()

    try:
        deployments_dict = []
        for d in deployments:
            try:
                d_dict = MessageToDict(d._pb)
            except AttributeError:
                d_dict = MessageToDict(d)
            deployments_dict.append(d_dict)
        print(json.dumps(deployments_dict, indent=2))
    except Exception:
        # Fallback to string representation if serialization fails
        print([str(d) for d in deployments])


def deployments_create(args: argparse.Namespace) -> None:
    """Creates a deployment."""
    from cxas_scrapi.core.deployments import Deployments

    print(f"Creating deployment {args.deployment_id} for App: {args.app_name}")

    traffic_split = None
    if getattr(args, "traffic_split", None):
        try:
            split_parts = args.traffic_split.split(",")
            traffic_split = {}
            for part in split_parts:
                k, v = part.split(":")
                traffic_split[k] = int(v)
        except Exception as e:
            print(f"Error parsing traffic-split: {e}")
            sys.exit(1)

    version_id = getattr(args, "version", None) or getattr(
        args, "version_id", None
    )
    if not version_id and not traffic_split:
        print(
            "Error: You must provide either `--version` (or `--version-id`)"
            " OR `--traffic-split`."
        )
        sys.exit(1)

    display_name = getattr(args, "display_name", None) or args.deployment_id
    channel_type = getattr(args, "channel_type", None) or "API"

    deployments_client = Deployments(app_name=args.app_name)
    deployment = deployments_client.create_deployment(
        deployment_id=args.deployment_id,
        display_name=display_name,
        app_version=version_id,
        channel_type=channel_type,
        traffic_split=traffic_split,
    )
    print(f"Deployment created successfully: {deployment.name}")


def deployments_promote(args: argparse.Namespace) -> None:
    """Promotes app to live traffic."""
    from google.api_core.exceptions import NotFound

    from cxas_scrapi.cli.app import app_push
    from cxas_scrapi.core.deployments import Deployments

    has_id = getattr(args, "deployment_id", None)
    has_split_or_ver = getattr(args, "version", None) or getattr(
        args, "traffic_split", None
    )
    if has_id and has_split_or_ver:
        app_name = args.app_name or getattr(args, "app_resource_name", None)
        print(
            f"Updating deployment {args.deployment_id} for App: {app_name}..."
        )
        deployments_client = Deployments(app_name=app_name)

        traffic_split = None
        if getattr(args, "traffic_split", None):
            try:
                split_parts = args.traffic_split.split(",")
                traffic_split = {}
                for part in split_parts:
                    k, v = part.split(":")
                    traffic_split[k] = int(v)
            except Exception as e:
                print(f"Error parsing traffic-split: {e}")
                sys.exit(1)

        kwargs = {}
        if getattr(args, "version", None):
            kwargs["app_version"] = args.version
        if traffic_split:
            kwargs["traffic_split"] = traffic_split

        try:
            deployments_client.update_deployment(
                deployment_id=args.deployment_id, **kwargs
            )
            print("Successfully updated deployment traffic.")
            return
        except Exception as e:
            print(f"Error updating deployment: {e}")
            sys.exit(1)

    if not all(
        [
            getattr(args, "app_resource_name", None),
            getattr(args, "app_dir", None),
            getattr(args, "live_deployment_resource_name", None),
        ]
    ):
        print(
            "Error: Missing required arguments. "
            "You must provide either `--deployment-id` with"
            " `--version`/`--traffic-split`, OR the legacy arguments: "
            "`--app-resource-name`, `--app-dir`, and "
            "`--live-deployment-resource-name`."
        )
        sys.exit(1)

    print(f"Promoting app {args.app_resource_name} to live traffic...")

    # Step 1: Push and create version
    push_args = argparse.Namespace(
        app_dir=args.app_dir,
        to=args.app_resource_name,
        app_name=None,
        display_name=None,
        env_file=None,
        project_id=None,
        location=None,
        create_version=True,
        version_description=f"Promote {time.strftime('%Y%m%d%H%M%S')}",
    )

    try:
        print("Calling app_push directly...")
        app_name = app_push(push_args)
        if not app_name:
            print("Error: Push failed during promotion.")
            sys.exit(1)

        version_id = getattr(push_args, "created_version_name", None)
        if not version_id:
            print("Error: Could not get created version ID.")
            sys.exit(1)

        # Step 2: Update deployment
        deployment_id = args.live_deployment_resource_name.split(
            "/deployments/"
        )[-1]

        deployments_client = Deployments(app_name=args.app_resource_name)

        try:
            deployments_client.get_deployment(deployment_id=deployment_id)
        except NotFound:
            print(f"Error: Deployment '{deployment_id}' does not exist.")
            print(
                "`deployments promote` requires "
                "promoting an existing deployment."
            )
            print(
                "Please create the deployment first using `deployments create`."
            )
            sys.exit(1)

        print(
            f"Updating deployment {deployment_id} with version {version_id}..."
        )
        deployments_client.update_deployment(
            deployment_id=deployment_id, app_version=version_id
        )

        print("Successfully promoted agent to live traffic.")

    except Exception as e:
        print(f"Error during promotion: {e}")
        sys.exit(1)


def cmd_help(args: argparse.Namespace) -> None:
    """Handles the 'help' command."""
    parser = get_parser()
    if getattr(args, "help_command", None):
        with contextlib.suppress(SystemExit):
            parser.parse_args([args.help_command, "--help"])
    else:
        parser.print_help()


def get_parser() -> argparse.ArgumentParser:
    """Sets up the argument parser."""
    description = (
        "CXAS SCRAPI Command Line Interface — Full CI/CD Suite\n\n"
        "The cxas CLI puts the full power of CX Agent Studio in your\n"
        "terminal: pull/push apps, run evals, manage versions, analyze\n"
        "conversation traces, and lint configurations or prompts.\n\n"
        "--- Key Verification & Linting Tools — When to Run Which ---\n\n"
        "• cxas lint     : Fast, deterministic structural linter.\n"
        "                  Validates directory layout, YAML/JSON schemas,\n"
        "                  app.yaml/app.json correctness, & basic structure.\n"
        "                  When to run: Continuously during development,\n"
        "                  in pre-commit hooks, and as a first CI gate.\n\n"
        "• cxas llm-lint : AI semantic natural language prompt linter.\n"
        "                  Analyzes instructions (instruction.txt,\n"
        "                  global_instruction.txt, & dynamic callbacks)\n"
        "                  for clarity, tone, persona, & contradictions.\n"
        "                  When to run: When authoring prompt engineering,\n"
        "                  before code reviews, or during qualitative QA."
    )

    parser = argparse.ArgumentParser(
        description=description,
        formatter_class=argparse.RawTextHelpFormatter,
    )

    parser.add_argument(
        "--oauth-token",
        help=(
            "Optional: OAuth token string for CES API authentication. "
            "Alternatively, set CXAS_OAUTH_TOKEN env var."
        ),
        required=False,
    )

    parser.add_argument(
        "--no-input",
        action="store_true",
        help=(
            "Disable all interactive prompts. Use this in CI/CD pipelines "
            "to prevent hanging on unexpected prompts."
        ),
    )

    def _add_project_location_args(
        subparser: argparse.ArgumentParser, required: bool = True
    ) -> None:
        """Helper to add standard GCP args to subparsers."""
        help_suffix = "" if required else " (Optional if using Display Name)"
        subparser.add_argument(
            "--project-id",
            required=required,
            help=f"The GCP Project ID.{help_suffix}",
        )
        subparser.add_argument(
            "--location",
            required=required,
            help=f"The GCP Location (e.g., global, us-central1).{help_suffix}",
        )

    subparsers = parser.add_subparsers(
        title="Commands", dest="command", required=True
    )

    # Parser for 'migrate'
    parser_migrate = subparsers.add_parser("migrate", help="Migration tools.")
    migrate_subparsers = parser_migrate.add_subparsers(
        title="Migration Commands", dest="migrate_command", required=True
    )

    parser_migrate_dfcx = migrate_subparsers.add_parser(
        "dfcx",
        help=(
            "Launch the interactive DFCX migration TUI dashboard, or run "
            "non-interactive --run/--optimize flows."
        ),
        formatter_class=argparse.RawTextHelpFormatter,
    )

    # Mode selection group
    mode_group = parser_migrate_dfcx.add_mutually_exclusive_group(
        required=False
    )
    mode_group.add_argument(
        "--run",
        action="store_true",
        help="Run end-to-end scriptable DFCX→CXAS migration non-interactively.",
    )
    mode_group.add_argument(
        "--optimize",
        action="store_true",
        help=(
            "Run checkpoint-level optimization stages or resume menu "
            "non-interactively."
        ),
    )

    # General / TUI arguments
    default_name_ts = datetime.datetime.now().strftime("ma-%m%d-%H%M")
    parser_migrate_dfcx.add_argument(
        "--default-agent-name",
        default=default_name_ts,
        help=(
            "Default name for the target agent "
            f"(TUI Mode / Fallback, default: '{default_name_ts}')."
        ),
    )

    # E2E Migration Arguments (active when --run is specified)
    e2e_group = parser_migrate_dfcx.add_argument_group(
        "End-to-End Migration Options (--run)"
    )
    src_group = e2e_group.add_mutually_exclusive_group(required=False)
    src_group.add_argument(
        "--source-agent-id",
        help=(
            "The source DFCX Agent ID (projects/.../locations/.../agents/...)."
        ),
    )
    src_group.add_argument(
        "--source-zip",
        help="Path to a local DFCX agent export (.zip) file.",
    )
    e2e_group.add_argument(
        "--project-id",
        help=(
            "Target GCP Project ID for CXAS deployment "
            "(Required for non-interactive modes)."
        ),
    )
    e2e_group.add_argument(
        "--location",
        default="us",
        help="Target GCP Location for CXAS deployment (Default: 'us').",
    )
    e2e_group.add_argument(
        "--target-name",
        help="The display name prefix / bundle target for the migrated app.",
    )
    e2e_group.add_argument(
        "--env",
        choices=["PROD", "AUTOPUSH"],
        default="PROD",
        help="CXAS Environment to target (PROD or AUTOPUSH).",
    )
    e2e_group.add_argument(
        "--model",
        default=DEFAULT_MODEL,
        help=(
            "The Gemini model to use for translation & optimization "
            f"(Default: {DEFAULT_MODEL})."
        ),
    )
    e2e_group.add_argument(
        "--profile",
        choices=["standard", "direct", "custom"],
        default="standard",
        help=(
            "The E2E migration profile configuration:\n"
            "  * standard: standard best practices (dedup + N->M TUI "
            "consolidation + Stage 3 wiring)\n"
            "  * direct: baseline fast 1:1 transpile (no "
            "optimizations/consolidation)\n"
            "  * custom: allows overriding via individual switches below"
        ),
    )
    e2e_group.add_argument(
        "--no-optimize",
        action="store_true",
        help=(
            "DEPRECATED alias for --no-consolidate; kept for backward"
            " compatibility. Skip Stage 1 + Stage 2 + Stage 3 entirely."
        ),
    )
    e2e_group.add_argument(
        "--no-consolidate",
        action="store_true",
        help=(
            "Skip Stage 1/2/3 entirely. Produces a 1:1 transpile (every"
            " source flow becomes its own CXAS agent). Use only when you"
            " need the unoptimized form. Default: consolidate."
        ),
    )
    e2e_group.add_argument(
        "--persist-bundle",
        action="store_true",
        help=(
            "Custom Mode: Persist intermediate IR bundle JSON for "
            "stage-resumability."
        ),
    )
    e2e_group.add_argument(
        "--yes",
        "-y",
        action="store_true",
        help="Non-interactive mode: auto-confirm stages and operations.",
    )

    # Grouping confirmation gate (Phase 4 — opt-in until Phase 5 default flip)
    gate_group = parser_migrate_dfcx.add_argument_group(
        "Grouping Confirmation Gate"
    )
    gate_group.add_argument(
        "--no-web-confirm",
        action="store_true",
        help=(
            "Skip the HTML grouping-confirmation gate and fall back to the"
            " InquirerPy terminal TUI for grouping review."
        ),
    )
    gate_group.add_argument(
        "--auto-confirm-grouping",
        action="store_true",
        help=(
            "Accept Gemini's proposed grouping without showing the review"
            " gate. Intended for CI / scripted migrations."
        ),
    )
    gate_group.add_argument(
        "--web-confirm-host",
        default="127.0.0.1",
        help=(
            "Bind host for the review server. Use 0.0.0.0 to allow access"
            " from another machine. Default: 127.0.0.1"
        ),
    )
    gate_group.add_argument(
        "--web-confirm-port",
        type=int,
        default=0,
        help="Bind port for the review server. 0 picks an ephemeral port.",
    )
    gate_group.add_argument(
        "--web-confirm-timeout",
        type=int,
        default=1800,
        help=(
            "Seconds to wait for the user to confirm before aborting."
            " Default: 1800 (30 min)."
        ),
    )

    xprs_group = parser_migrate_dfcx.add_argument_group(
        "Agent xprs Designer (Experimental Feature Gate)"
    )
    xprs_group.add_argument(
        "--experimental-agent-xprs",
        action="store_true",
        help=(
            "Enable experimental Agent xprs interactive designer and background"
            " utterance harvesting (in progress, not fully connected)."
        ),
    )

    # Optimization/Checkpoint Arguments (active when --optimize is specified)
    opt_group = parser_migrate_dfcx.add_argument_group(
        "Optimization / Checkpoint Stage Options (--optimize)"
    )
    opt_group.add_argument(
        "--stage",
        choices=["1", "2", "3", "resume"],
        help=(
            "The specific optimization stage or resume menu to invoke "
            "(Required for --optimize)."
        ),
    )
    opt_group.add_argument(
        "--ir-bundle",
        help="Path to an existing <target>_ir.json bundle file.",
    )
    opt_group.add_argument(
        "--version-label",
        help=(
            "CXAS Version display_name to create after the stage "
            "(Default: '0.0.3' for stage 1, '0.0.4' for stage 2, "
            "'0.0.5' for stage 3)."
        ),
    )
    opt_group.add_argument(
        "--no-persist",
        action="store_true",
        help="Skip writing the updated bundle state back to disk.",
    )
    opt_group.add_argument(
        "--no-unit-tests",
        action="store_true",
        help=(
            "Stage 2: Skip deterministic unit-test goldens/scenarios "
            "generation."
        ),
    )
    opt_group.add_argument(
        "--no-lint",
        action="store_true",
        help=(
            "Stage 2: Skip running local post-deploy schema and practice "
            "linters."
        ),
    )
    opt_group.add_argument(
        "--no-report",
        action="store_true",
        help=(
            "Stage 2: Skip generating the detailed optimization markdown "
            "audit log."
        ),
    )
    opt_group.add_argument(
        "--architecture",
        choices=["hub-and-spoke", "original-hierarchy"],
        default="hub-and-spoke",
        help=(
            "Stage 3: Spoke-Hub architecture style mapping to compile "
            "child routing (Default: 'hub-and-spoke')."
        ),
    )

    parser_migrate_dfcx.set_defaults(func=run_migration_dashboard)

    # Parser for 'init-github-action'
    parser_init_gh = subparsers.add_parser(
        "init-github-action",
        help="Generate a GitHub Actions workflow file for testing the agent.",
    )
    parser_init_gh.add_argument(
        "--app-dir",
        help=(
            "Optional: The path to the app directory (e.g., 'pilot') "
            "to extract app_name and agent_name from app.yaml."
        ),
    )
    parser_init_gh.add_argument(
        "--app-name",
        help=(
            "Optional: The CXAS App ID (projects/.../apps/...). "
            "If missing, extracts from app_dir/app.yaml."
        ),
    )
    parser_init_gh.add_argument(
        "--agent-name",
        help=(
            "Optional: The name of the agent directory to scope the workflow "
            "to (e.g., 'pilot')."
        ),
    )

    parser_init_gh.add_argument(
        "--workload-identity-provider",
        help="Optional: GCP Workload Identity Provider string.",
    )
    parser_init_gh.add_argument(
        "--service-account",
        help="Optional: GCP Service Account email.",
    )
    parser_init_gh.add_argument(
        "--output",
        help=(
            "Optional: Override path where the workflow file will be saved. "
            "Defaults to .github/workflows/test_{agent_name}.yml"
        ),
    )

    _add_project_location_args(parser_init_gh, required=False)

    parser_init_gh.add_argument(
        "--branch",
        default="main",
        help=(
            "Optional: Target branch for deploy trigger (e.g. main). "
            "Defaults to 'main'."
        ),
    )
    parser_init_gh.add_argument(
        "--no-cleanup",
        action="store_true",
        help="Optional: Skip generation of the cleanup workflow.",
    )
    parser_init_gh.add_argument(
        "--install-hook",
        action="store_true",
        help=(
            "Optional: Install a git pre-push hook to run local-test "
            "automatically."
        ),
    )
    parser_init_gh.add_argument(
        "--auto-create-wif",
        action="store_true",
        help=(
            "Optional: Automatically create Workload "
            "Identity Pool, Provider, and Service "
            "Account on Google Cloud."
        ),
    )
    parser_init_gh.add_argument(
        "--wif-pool-name",
        default="github-actions-pool-scrapi",
        help="Optional: The name of the Workload Identity Pool to create/use.",
    )
    parser_init_gh.add_argument(
        "--github-repo",
        help=(
            "Optional: Override inferred GitHub repository (e.g., owner/repo)."
        ),
    )

    parser_init_gh.set_defaults(func=init_github_action)

    parser_evals = subparsers.add_parser("evals", help="Manage evaluations.")
    evals_subparsers = parser_evals.add_subparsers(dest="evals_command")
    parser_report = evals_subparsers.add_parser(
        "report",
        help="Generate combined report for golden + simulation results.",
    )
    parser_report.add_argument(
        "--output-dir",
        required=True,
        help="Directory containing eval results (sim_results.json, etc.).",
    )
    parser_report.add_argument(
        "--output",
        help=(
            f"Output path. Defaults to <evals-dir>/{COMBINED_REPORT_FILENAME} "
            f"(or {COMBINED_REPORT_JSON_FILENAME} with --format json)."
        ),
    )
    parser_report.add_argument(
        "--format",
        choices=["html", "json"],
        default="html",
        help=(
            "Output format for the combined report: 'html' (default) or "
            "'json' for a machine-readable report with the same data."
        ),
    )
    parser_report.add_argument(
        "--golden-run",
        help="Optional: Golden eval run ID to fetch from server.",
    )
    parser_report.add_argument(
        "--app-name",
        help="Optional: App resource name (projects/.../apps/...)",
    )
    parser_report.add_argument(
        "--run",
        action="store_true",
        help="Run evaluations before generating report.",
    )
    parser_report.add_argument(
        "--app-dir",
        help="Directory of the app (used for callback tests).",
    )
    parser_report.add_argument(
        "--input-dir",
        help=(
            "Base directory containing goldens/, simulations/, "
            "and tool_tests/ subdirectories."
        ),
    )
    parser_report.add_argument(
        "--tool-test-file",
        default="evals/tool_tests/",
        help="Path to tool test file or directory.",
    )
    parser_report.add_argument(
        "--goldens-dir",
        default="evals/goldens/",
        help="Path to goldens directory or file to push.",
    )
    parser_report.add_argument(
        "--simulation-dir",
        default="evals/simulations/",
        help="Path to simulation files directory.",
    )
    parser_report.add_argument(
        "--gcs-path",
        help="Optional: GCS path to store the combined report (starts with gs://).",
    )
    parser_report.add_argument(
        "--runs",
        type=int,
        default=1,
        help="Number of runs per golden and simulation test case.",
    )
    parser_report.add_argument(
        "--sim-parallel",
        type=int,
        default=5,
        help=(
            "Number of parallel worker sessions for simulations. Defaults to 5."
        ),
    )
    parser_report.add_argument(
        "--modality",
        choices=["text", "audio"],
        default="text",
        help="Evaluation execution modality (text or audio). Defaults to text.",
    )
    parser_report.add_argument(
        "--sim-user-model",
        help=(
            "Gemini model name to use for the simulated user "
            "(default: gemini-3.1-flash-lite)."
        ),
    )
    parser_report.add_argument(
        "--eval-model",
        help=(
            "Gemini model name to use for evaluating expectations "
            "(default: gemini-3.1-flash-lite)."
        ),
    )
    parser_report.add_argument(
        "--include",
        default="sims,goldens,tools,callbacks",
        help=(
            "Categories to include (comma-separated, "
            "default: sims,goldens,tools,callbacks)."
        ),
    )
    parser_report.add_argument(
        "--filter-files",
        help="Optional: Comma-separated list of filenames to include.",
    )
    parser_report.add_argument(
        "--filter-tags",
        help="Optional: Comma-separated list of tags to include.",
    )
    parser_report.add_argument(
        "--golden-timeout",
        type=int,
        default=600,
        help="Timeout in seconds waiting for remote goldens. Defaults to 600.",
    )
    parser_report.add_argument(
        "--bg-noise-file",
        help="Optional: Path to continuous background noise audio file.",
    )
    parser_report.add_argument(
        "--burst-noise-files",
        help=(
            "Optional: Comma-separated list of paths to burst noise audio "
            "files."
        ),
    )
    parser_report.add_argument(
        "--use-tool-fakes",
        action="store_true",
        help="Enable tool fakes (bypass real tool backends).",
    )
    parser_report.add_argument(
        "--timestamped",
        action="store_true",
        help="If set, nests the output files in a timestamped subdirectory.",
    )
    parser_report.add_argument(
        "--expectations-only",
        action="store_true",
        default=False,
        help=(
            "Evaluate test results using only expectations "
            "(ignore goal success_criteria)"
        ),
    )
    parser_report.add_argument(
        "--filter-names",
        help="Optional: Comma-separated list of evaluation names to include.",
    )
    parser_report.add_argument(
        "--json-progress",
        action="store_true",
        help="Output progress updates as JSON lines to stderr.",
    )
    parser_report.add_argument(
        "--deployment-id",
        default=None,
        help="Optional: Target a specific deployment ID for simulations.",
    )
    parser_report.add_argument(
        "--capture-agent-audio",
        action="store_true",
        help="Capture real-time agent output audio as WAV files",
    )
    parser_report.add_argument(
        "--single-bidi-stream",
        action="store_true",
        help="Keep one persistent bidi WebSocket open per audio simulation instead of one connection per turn.",
    )
    parser_report.set_defaults(func=combined_evals_report_cmd)

    parser_test_tools = subparsers.add_parser(
        "test-tools",
        help="Run local tool unit tests against the deployed agent.",
    )
    parser_test_tools.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_test_tools.add_argument(
        "--test-file",
        required=True,
        help="Path to the YAML/JSON file containing tool test definitions.",
    )
    parser_test_tools.add_argument(
        "--debug",
        action="store_true",
        help="Enable debug logging for tool executions.",
    )

    parser_test_tools.set_defaults(func=test_tools)

    # Parser for 'test-callbacks'
    parser_test_callbacks = subparsers.add_parser(
        "test-callbacks",
        help="Run local callback unit tests against the deployed agent.",
    )
    parser_test_callbacks.add_argument(
        "--app-dir",
        required=True,
        help="The path to the app directory.",
    )
    parser_test_callbacks.add_argument(
        "--agent-name",
        required=False,
        help="Optional: The name of the agent to run callback tests for.",
    )
    parser_test_callbacks.add_argument(
        "--callback-type",
        required=False,
        help="Optional: The type of callback to run tests for.",
    )
    parser_test_callbacks.add_argument(
        "--callback-name",
        required=False,
        help="Optional: The name of the callback to run tests for.",
    )
    parser_test_callbacks.add_argument(
        "--log-file",
        required=False,
        help="Optional: Path to a file to log pytest output to.",
    )
    parser_test_callbacks.add_argument(
        "--pytest-args",
        type=lambda s: [item for item in s.split(",")],
        help='Comma-separated list (e.g., "-v,-s")',
    )

    parser_test_callbacks.set_defaults(func=test_callbacks)

    # Parser for 'test-single-callback'
    parser_test_single_callback = subparsers.add_parser(
        "test-single-callback",
        help="Run local callback unit tests against the deployed agent.",
    )
    parser_test_single_callback.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_test_single_callback.add_argument(
        "--agent-name",
        required=True,
        help="Optional: The name of the agent to run callback tests for.",
    )
    parser_test_single_callback.add_argument(
        "--callback-type",
        required=True,
        help="Optional: The type of callback to run tests for.",
    )
    parser_test_single_callback.add_argument(
        "--test-file-path",
        required=True,
        help="Path to the test python file to run.",
    )
    parser_test_single_callback.add_argument(
        "--log-file",
        required=False,
        help="Optional: Path to a file to log pytest output to.",
    )
    parser_test_single_callback.add_argument(
        "--pytest-args",
        type=lambda s: [item for item in s.split(",")],
        help='Comma-separated list (e.g., "-v,-s")',
    )

    parser_test_single_callback.set_defaults(func=test_single_callback)

    # Parser for 'export'
    parser_export = subparsers.add_parser(
        "export", help="Export an evaluation to YAML or JSON format."
    )
    parser_export.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_export.add_argument(
        "--evaluation-id",
        required=True,
        help=(
            "The evaluation resource name "
            "(projects/.../locations/.../apps/.../evaluations/...)."
        ),
    )
    parser_export.add_argument(
        "--format",
        choices=["yaml", "json"],
        default="yaml",
        help="Export format (yaml or json). Defaults to yaml.",
    )
    parser_export.add_argument(
        "--output",
        help=(
            "Path to save the exported evaluation. "
            "If not provided, prints to stdout."
        ),
    )

    parser_export.set_defaults(func=export_eval)

    # Parser for 'push'
    parser_push_eval = subparsers.add_parser(
        "push-eval", help="Push evaluation(s) from a YAML file to the app."
    )
    parser_push_eval.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_push_eval.add_argument(
        "--file",
        required=True,
        help="Path to the YAML file containing evaluation definitions.",
    )
    parser_push_eval.set_defaults(func=push_eval)

    # Parser for 'run'
    parser_run = subparsers.add_parser(
        "run", help="Run an evaluation and assert results."
    )
    parser_run.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_run.add_argument(
        "--evaluation-id",
        required=False,
        help=(
            "The evaluation resource name "
            "(projects/.../locations/.../apps/.../evaluations/...)."
        ),
    )
    parser_run.add_argument(
        "--modality",
        choices=["text", "audio"],
        default="text",
        help="Evaluation execution modality (text or audio). Defaults to text.",
    )
    parser_run.add_argument(
        "--display-name-prefix",
        required=False,
        help="Run all tests whose display name starts with this string.",
    )
    parser_run.add_argument(
        "--tags",
        nargs="+",
        default=[],
        help=(
            "Space-separated list of tags. Runs tests "
            "containing any of these tags."
        ),
    )
    parser_run.add_argument(
        "--wait",
        action="store_true",
        help=(
            "Wait for evaluation to complete and return exit code 0 "
            "on pass or 1 on fail."
        ),
    )
    parser_run.add_argument(
        "--filter-auto-metrics",
        action="store_true",
        help=(
            "Filter out automated metrics (semantic similarity, "
            "hallucination) and only evaluate custom expectations."
        ),
    )
    parser_run.add_argument(
        "--golden-run-method",
        choices=["STABLE", "NAIVE"],
        default="STABLE",
        help=(
            "Method used to replay golden tests (STABLE or NAIVE). "
            "Defaults to STABLE."
        ),
    )

    parser_run.set_defaults(func=run_eval)

    # Parser for 'run-session'
    parser_run_session = subparsers.add_parser(
        "run-session",
        help="Start an interactive text session with the agent.",
    )
    parser_run_session.add_argument(
        "modality",
        choices=["text"],
        help="Modality of the session.",
    )
    parser_run_session.add_argument(
        "app_name",
        help="The app name (projects/.../locations/.../apps/...).",
    )
    parser_run_session.add_argument(
        "--use-tool-fakes",
        action="store_true",
        default=False,
        help="Use fake tools for the session if available.",
    )
    parser_run_session.set_defaults(func=run_session)

    # Parser for 'ci-test'
    parser_ci_test = subparsers.add_parser(
        "ci-test", help="Runs standard integration tests on a temporary agent."
    )
    parser_ci_test.add_argument(
        "--app-dir",
        default=".",
        help=(
            "Path to the app directory to test. Defaults to current directory."
        ),
    )
    parser_ci_test.add_argument(
        "--display-name",
        help=(
            "Optional: Deterministic display name for the temp agent "
            "(e.g. [CI] PR-123). Overwrites existing."
        ),
    )
    parser_ci_test.add_argument(
        "--env-file",
        help=(
            "Path to a specific environment JSON "
            "file to include as environment.json."
        ),
    )
    _add_project_location_args(parser_ci_test)
    parser_ci_test.set_defaults(func=ci_test)

    # Parser for 'delete'
    parser_delete = subparsers.add_parser(
        "delete", help="Deletes a specified agent/app."
    )
    parser_delete.add_argument(
        "--app-name",
        help=(
            "The CXAS App ID (projects/.../locations/.../apps/...). "
            "Required if --display-name not provided."
        ),
    )
    parser_delete.add_argument(
        "--display-name",
        help=(
            "The Display Name of the app to delete. "
            "Required if --app-name not provided."
        ),
    )
    _add_project_location_args(parser_delete, required=False)
    parser_delete.add_argument(
        "--force",
        action="store_true",
        help="Force delete even if there are child resources.",
    )
    parser_delete.set_defaults(func=app_delete)

    # Parser for 'local-test'
    parser_local_test = subparsers.add_parser(
        "local-test", help="Runs the agent tests locally using Docker."
    )
    parser_local_test.add_argument(
        "--app-dir",
        default=".",
        help="Path to the app directory. Defaults to current directory.",
    )
    parser_local_test.add_argument(
        "--env-file",
        help=(
            "Path to a specific environment JSON "
            "file to include as environment.json."
        ),
    )
    _add_project_location_args(parser_local_test)
    parser_local_test.set_defaults(func=local_test)

    # Parser for 'pull'
    parser_pull = subparsers.add_parser(
        "pull", help="Export an app to a local directory."
    )
    parser_pull.add_argument("app", help="App Resource Name or Display Name.")
    parser_pull.add_argument(
        "--target-dir", default=".", help="Directory to extract to."
    )
    parser_pull.add_argument(
        "--overwrite",
        action="store_true",
        help=(
            "Overwrite existing target directory data with exported data. "
            "Existing resources that do not have a matching display name in "
            "the exported app will be deleted."
        ),
    )
    _add_project_location_args(parser_pull, required=False)
    parser_pull.set_defaults(func=app_pull)

    # Parser for 'push'
    parser_push = subparsers.add_parser(
        "push", help="Import local files back to CXAS."
    )
    parser_push.add_argument(
        "--app-dir", default=".", help="Local app directory."
    )
    parser_push.add_argument(
        "--to", help="Target App Resource Name or Display Name."
    )
    parser_push.add_argument(
        "--env-file",
        help=(
            "Path to a specific environment JSON "
            "file to include as environment.json."
        ),
    )
    parser_push.add_argument(
        "--app-name",
        help="Target App ID to explicitly push to (v1beta API).",
    )
    parser_push.add_argument(
        "--display-name",
        help="Display name for a new App if --to is not provided.",
    )
    _add_project_location_args(parser_push, required=False)
    parser_push.add_argument(
        "--create-version",
        action="store_true",
        help="Create a version after successful push.",
    )
    parser_push.add_argument(
        "--version-description",
        help="Description for the created version.",
    )
    parser_push.add_argument(
        "--overwrite",
        action="store_true",
        help=(
            "Overwrite existing data with imported data. Existing resources "
            "that do not have a matching display name in the imported app "
            "will be deleted"
        ),
    )
    parser_push.set_defaults(func=app_push)

    # Parser for 'lint'
    parser_lint = subparsers.add_parser(
        "lint",
        help="Lint an app directory for best practices and structural issues.",
    )
    parser_lint.add_argument(
        "--app-dir",
        default=".",
        help="Path to the app directory to lint (default: current directory).",
    )
    parser_lint.add_argument(
        "--fix",
        action="store_true",
        help="Show fix suggestions for each issue.",
    )
    parser_lint.add_argument(
        "--only",
        choices=[
            "instructions",
            "callbacks",
            "tools",
            "evals",
            "config",
            "structure",
            "schema",
        ],
        help="Only run a specific linter category.",
    )
    parser_lint.add_argument(
        "--rule",
        type=str,
        help="Run specific rules only (comma-separated IDs, e.g. I003,C005).",
    )
    parser_lint.add_argument(
        "--json",
        action="store_true",
        dest="json_output",
        help="Output results as JSON.",
    )
    parser_lint.add_argument(
        "--list-rules",
        action="store_true",
        help="List all available lint rules.",
    )
    parser_lint.add_argument(
        "--validate-only",
        action="store_true",
        help="Run only structure and config rules.",
    )
    parser_lint.add_argument(
        "--agents",
        help="Only discover/lint specific agents (comma-separated list).",
    )
    parser_lint.add_argument(
        "--tools",
        help="Only discover/lint specific tools (comma-separated list).",
    )
    parser_lint.add_argument(
        "--agent",
        help="Validate a single agent directory against CES schema.",
    )
    parser_lint.add_argument(
        "--tool",
        help="Validate a single tool directory against CES schema.",
    )
    parser_lint.add_argument(
        "--toolset",
        help="Validate a single toolset directory against CES schema.",
    )
    parser_lint.add_argument(
        "--guardrail",
        help="Validate a single guardrail directory against CES schema.",
    )
    parser_lint.add_argument(
        "--evaluation",
        help="Validate a single evaluation directory against CES schema.",
    )
    parser_lint.add_argument(
        "--evaluation-expectations",
        help=(
            "Validate a single evaluation expectations"
            " directory against CES schema."
        ),
    )
    parser_lint.set_defaults(func=app_lint)

    # Parser for 'llm-lint'
    parser_llm_lint = subparsers.add_parser(
        "llm-lint",
        help="Run AI-driven semantic linter on GECX sub-agent instructions.",
    )
    parser_llm_lint.add_argument(
        "--agent-dir",
        required=True,
        help="Path to the sub-agent directory containing instruction.txt.",
    )
    parser_llm_lint.add_argument(
        "--project-id",
        help="GCP Project ID (auto-detected if omitted).",
    )
    parser_llm_lint.add_argument(
        "--location",
        default="us-central1",
        help="GCP location for Vertex AI queries (default: us-central1).",
    )
    parser_llm_lint.add_argument(
        "--model",
        default="gemini-2.5-flash",
        help="Gemini model name to use (default: gemini-2.5-flash).",
    )
    parser_llm_lint.add_argument(
        "--output",
        help="Optional path to write the markdown lint report.",
    )
    parser_llm_lint.set_defaults(func=llm_lint)

    # Parser for 'help'
    parser_help = subparsers.add_parser(
        "help", help="Show help for the CLI or a specific command."
    )
    parser_help.add_argument(
        "help_command",
        nargs="?",
        help="The command to show help for (e.g., lint, llm-lint, run).",
    )
    parser_help.set_defaults(func=cmd_help)

    # Parser for 'init'
    parser_init = subparsers.add_parser(
        "init",
        help="Initialize a project with CXAS agent development skills "
        "(.agents, .claude, .gemini, AGENTS.md, etc.).",
    )
    parser_init.add_argument(
        "--target-dir",
        default=".",
        help="Directory to install skills into (default: current directory).",
    )
    parser_init.add_argument(
        "--force",
        action="store_true",
        help="Overwrite existing files without prompting.",
    )
    parser_init.set_defaults(func=app_init)

    # Parser for 'create'
    parser_create = subparsers.add_parser("create", help="Create a new app.")
    parser_create.add_argument("name", help="Display name of the new app.")
    parser_create.add_argument(
        "--description", help="Description for the new app."
    )
    parser_create.add_argument(
        "--app-id", help="Optional specific app_id to use."
    )
    _add_project_location_args(parser_create)
    parser_create.set_defaults(func=app_create)

    # Parser for 'branch'
    parser_branch = subparsers.add_parser(
        "branch", help="Branch an app (pull -> create -> push)."
    )
    parser_branch.add_argument(
        "source", help="Source App Resource Name or Display Name."
    )
    parser_branch.add_argument(
        "--new-name", required=True, help="Display name of the new branch app."
    )
    _add_project_location_args(parser_branch)
    parser_branch.set_defaults(func=app_branch)

    # Subparsers for 'apps'
    parser_apps = subparsers.add_parser("apps", help="Manage apps (list, get).")
    apps_subparsers = parser_apps.add_subparsers(
        title="Apps Commands", dest="apps_command", required=True
    )

    parser_apps_list = apps_subparsers.add_parser("list", help="List all apps.")
    _add_project_location_args(parser_apps_list)
    parser_apps_list.set_defaults(func=apps_list)

    parser_apps_get = apps_subparsers.add_parser("get", help="Get app details.")
    parser_apps_get.add_argument(
        "app",
        help="App Resource Name or Display Name.",
    )
    _add_project_location_args(parser_apps_get, required=False)
    parser_apps_get.set_defaults(func=apps_get)

    # Subparsers for 'conversations'
    parser_convs = subparsers.add_parser(
        "conversations", help="Manage conversations (list, get)."
    )
    convs_subparsers = parser_convs.add_subparsers(
        title="Conversations Commands",
        dest="conversations_command",
        required=True,
    )

    parser_convs_list = convs_subparsers.add_parser(
        "list", help="List conversations for an app."
    )
    parser_convs_list.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_convs_list.set_defaults(func=conversations_list)

    parser_convs_get = convs_subparsers.add_parser(
        "get", help="Get conversation details."
    )
    parser_convs_get.add_argument(
        "conversation_resource_name",
        help="The conversation resource name.",
    )
    parser_convs_get.set_defaults(func=conversations_get)

    # Subparsers for 'deployments'
    parser_deps = subparsers.add_parser(
        "deployments", help="Manage deployments (list, create, promote)."
    )
    deps_subparsers = parser_deps.add_subparsers(
        title="Deployments Commands",
        dest="deployments_command",
        required=True,
    )

    parser_deps_list = deps_subparsers.add_parser(
        "list", help="List deployments for an app."
    )
    parser_deps_list.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_deps_list.set_defaults(func=deployments_list)

    parser_deps_create = deps_subparsers.add_parser(
        "create", help="Create a deployment."
    )
    parser_deps_create.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_deps_create.add_argument(
        "--deployment-id",
        required=True,
        help="Deployment ID for create_deployment.",
    )
    parser_deps_create.add_argument(
        "--version-id",
        required=False,
        help="Version ID for create_deployment.",
    )
    parser_deps_create.add_argument(
        "--version",
        required=False,
        help="Version ID for create_deployment.",
    )
    parser_deps_create.add_argument(
        "--display-name",
        required=False,
        help="Display name for the deployment.",
    )
    parser_deps_create.add_argument(
        "--channel-type",
        required=False,
        help="Channel type (e.g. API).",
    )
    parser_deps_create.add_argument(
        "--traffic-split",
        required=False,
        help=(
            "Either version or traffic split needs to be specified. Split "
            "traffic between multiple app versions by percentage, colon "
            "delimited. Traffic must sum to 100%%. Format: "
            "version_id1:traffic_percentage1,version_id2:traffic_percentage2 "
            '(e.g. "v1:90,v2:10").'
        ),
    )
    _add_project_location_args(parser_deps_create, required=False)
    parser_deps_create.set_defaults(func=deployments_create)

    parser_deps_promote = deps_subparsers.add_parser(
        "promote", help="Promote app to live traffic."
    )
    parser_deps_promote.add_argument(
        "--app-resource-name",
        required=False,
        help="Fully qualified CXAS app resource name.",
    )
    parser_deps_promote.add_argument(
        "--app-dir",
        required=False,
        help="Path to the CXAS app directory.",
    )
    parser_deps_promote.add_argument(
        "--live-deployment-resource-name",
        required=False,
        help="Fully qualified live deployment resource name.",
    )
    parser_deps_promote.add_argument(
        "--app-name",
        required=False,
        help="The CXAS App ID.",
    )
    parser_deps_promote.add_argument(
        "--deployment-id",
        required=False,
        help="Deployment ID.",
    )
    parser_deps_promote.add_argument(
        "--version",
        required=False,
        help="Version ID to promote.",
    )
    parser_deps_promote.add_argument(
        "--traffic-split",
        required=False,
        help=(
            "Either version or traffic split needs to be specified. Split "
            "traffic between multiple app versions by percentage, colon "
            "delimited. Traffic must sum to 100%%. Format: "
            "version_id1:traffic_percentage1,version_id2:traffic_percentage2 "
            '(e.g. "v1:90,v2:10").'
        ),
    )
    _add_project_location_args(parser_deps_promote, required=False)
    parser_deps_promote.set_defaults(func=deployments_promote)

    # Subparsers for 'local'
    parser_local = subparsers.add_parser(
        "local", help="Local workspace operations."
    )
    local_subparsers = parser_local.add_subparsers(
        title="Local Commands", dest="local_command", required=True
    )

    parser_local_create = local_subparsers.add_parser(
        "create", help="Create local templates for CXAS components."
    )
    local_create_subparsers = parser_local_create.add_subparsers(
        title="Create Local Commands",
        dest="create_local_command",
        required=True,
    )

    parser_local_create_agent = local_create_subparsers.add_parser(
        "agent", help="Create local agent template."
    )
    parser_local_create_agent.add_argument(
        "name", help="Display name of the agent."
    )
    parser_local_create_agent.add_argument(
        "--app-dir", default=".", help="App directory."
    )
    parser_local_create_agent.set_defaults(func=handle_local_create)

    parser_local_create_tool = local_create_subparsers.add_parser(
        "tool", help="Create local tool template."
    )
    parser_local_create_tool.add_argument(
        "name", help="Display name of the tool."
    )
    parser_local_create_tool.add_argument(
        "tool_type", nargs="?", help="Type of tool (e.g., PYTHON)."
    )
    parser_local_create_tool.add_argument(
        "--add-to-agent", nargs="?", help="Agent to add the tool to."
    )
    parser_local_create_tool.add_argument(
        "--app-dir", default=".", help="App directory."
    )
    parser_local_create_tool.set_defaults(func=handle_local_create)

    parser_local_create_guardrail = local_create_subparsers.add_parser(
        "guardrail", help="Create local guardrail template."
    )
    parser_local_create_guardrail.add_argument(
        "name", help="Display name of the guardrail."
    )
    parser_local_create_guardrail.add_argument(
        "guardrail_type",
        nargs="?",
        default="llm_policy",
        help="Type of guardrail (default: llm_policy).",
    )
    parser_local_create_guardrail.add_argument(
        "--app-dir", default=".", help="App directory."
    )
    parser_local_create_guardrail.set_defaults(func=handle_local_create)

    # Subparsers for 'versions'
    parser_versions = subparsers.add_parser(
        "versions", help="Manage CXAS app versions (list, compare)."
    )
    versions_subparsers = parser_versions.add_subparsers(
        title="Versions Commands", dest="versions_command", required=True
    )

    parser_versions_list = versions_subparsers.add_parser(
        "list", help="List all deployed versions of an app."
    )
    parser_versions_list.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    _add_project_location_args(parser_versions_list, required=False)
    parser_versions_list.set_defaults(func=app_versions_list)

    parser_versions_compare = versions_subparsers.add_parser(
        "compare",
        help="Compare two app versions and generate a human-readable diff.",
    )
    parser_versions_compare.add_argument(
        "--app-name",
        required=True,
        help="The CXAS App ID (projects/.../locations/.../apps/...).",
    )
    parser_versions_compare.add_argument(
        "--source",
        required=True,
        help="Source version ID (e.g., UUID).",
    )
    parser_versions_compare.add_argument(
        "--target",
        required=True,
        help="Target version ID (e.g., UUID).",
    )
    parser_versions_compare.add_argument(
        "--output",
        help="Optional path to save the Markdown/HTML comparison report.",
    )
    parser_versions_compare.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        help=(
            "Print detailed line-by-line diff directly to the console using "
            "rich formatting."
        ),
    )
    parser_versions_compare.add_argument(
        "--web",
        action="store_true",
        help=(
            "Force generate a self-contained interactive HTML diff report "
            "instead of console text."
        ),
    )
    _add_project_location_args(parser_versions_compare, required=False)
    parser_versions_compare.set_defaults(func=app_versions_compare)

    # Subparsers for 'tools', 'callbacks', and 'variables'
    register_resources_subparsers(subparsers)

    # Subparsers for 'insights'
    parser_insights = subparsers.add_parser(
        "insights",
        help="Perform high-level CXAS Insights and Quality AI operations.",
        formatter_class=argparse.RawTextHelpFormatter,
    )
    populate_insights_parser(parser_insights)

    # Subparsers for 'trace' — observability/debugging for past conversations.
    register_trace_subparser(subparsers)

    return parser


def main() -> None:
    parser = get_parser()
    args = parser.parse_args()

    if getattr(args, "oauth_token", None):
        os.environ["CXAS_OAUTH_TOKEN"] = args.oauth_token

    # Configure logging
    log_level = logging.INFO
    logging.basicConfig(
        level=log_level,
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    )

    if hasattr(args, "func"):
        args.func(args)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()
