# mypy: disable-error-code=misc
import asyncio
import sys
import time
from http import HTTPStatus
from typing import Any, Optional, Union

import structlog
from sanic import Blueprint, HTTPResponse, response
from sanic.request import Request
from sanic_openapi import openapi

import rasa
from rasa.builder.auth import (
    HEADER_USER_ID,
    email_from_auth,
    is_auth_required_now,
    protected,
)
from rasa.builder.config import (
    COPILOT_ASSISTANT_TRACKER_MAX_TURNS,
    DEFAULT_BOT_BUILDER_EMAIL,
    GUARDRAILS_ENABLE_BLOCKING,
    HELLO_RASA_PROJECT_ID,
    LAKERA_ASSISTANT_HISTORY_GUARDRAIL_PROJECT_ID,
    LAKERA_COPILOT_HISTORY_GUARDRAIL_PROJECT_ID,
)
from rasa.builder.copilot import get_copilot_class
from rasa.builder.copilot.constants import DEFAULT_COPILOT_CHAT_ID, ROLE_USER
from rasa.builder.copilot.copilot_templated_message_provider import (
    copilot_internal_message_templates,
)
from rasa.builder.copilot.exceptions import CopilotStreamError
from rasa.builder.copilot.history_store import persist_copilot_message_to_history
from rasa.builder.copilot.models import (
    ConversationKey,
    CopilotChatMessage,
    CopilotContext,
    CopilotHistoryResponse,
    CopilotTurnRequest,
    GeneratedContent,
    GuardrailBlockedContent,
    GuardrailPolicyViolationContent,
    ReferenceEntry,
    ReferenceSection,
    ResponseCategory,
    ResponseCompleteness,
    TextContent,
)
from rasa.builder.download import create_bot_project_archive
from rasa.builder.git_service import DEFAULT_COMMIT_INFO, CommitNotFoundError
from rasa.builder.guardrails.constants import (
    BLOCK_SCOPE_PROJECT,
    BLOCK_SCOPE_USER,
    BlockScope,
)
from rasa.builder.guardrails.store import guardrails_store
from rasa.builder.job_manager import job_manager
from rasa.builder.jobs import (
    run_backup_to_bot_job,
    run_change_branch_job,
    run_github_to_bot_job,
    run_prompt_to_bot_job,
    run_replace_all_files_job,
    run_rollback_job,
    run_template_to_bot_job,
)
from rasa.builder.llm_service import llm_service
from rasa.builder.logging_utils import (
    capture_exception_with_context,
    get_recent_logs,
)
from rasa.builder.models import (
    AgentStatus,
    ApiErrorResponse,
    AssistantInfo,
    BotData,
    BotFiles,
    ChangeBranchRequest,
    CommitDiffWithContentsResponse,
    GitCommitInfo,
    GitHubToBotRequest,
    GitStatusResponse,
    JobCreateResponse,
    JobStatus,
    JobStatusEvent,
    PromptRequest,
    RestoreFromBackupRequest,
    ServerSentEvent,
    TemplateRequest,
)
from rasa.builder.project_generator.project_generator import ProjectGenerator
from rasa.builder.shared.tracker_context import TrackerContext
from rasa.builder.telemetry.copilot_segment_telemetry import CopilotSegmentTelemetry
from rasa.builder.telemetry.langfuse.copilot_endpoint_langfuse_telemetry import (
    CopilotEndpointLangfuseTelemetry,
)
from rasa.builder.telemetry.langfuse.langfuse_compat import observe
from rasa.builder.training_service import (
    train_and_load_agent,
    try_load_existing_agent,
    update_agent,
)
from rasa.core.agent import Agent
from rasa.core.channels.studio_chat import StudioChatInput
from rasa.core.exceptions import AgentNotReady
from rasa.shared.core.flows.flows_list import FlowsList
from rasa.shared.core.flows.yaml_flows_io import get_flows_as_json
from rasa.shared.core.trackers import DialogueStateTracker
from rasa.shared.importers.utils import DOMAIN_KEYS
from rasa.utils.json_utils import extract_values
from rasa.utils.openapi import model_to_schema

# Error message constant for agent not ready state
AGENT_NOT_READY_ERROR = "Agent not ready"

structlogger = structlog.get_logger()

# Create the blueprint
bp = Blueprint("bot_builder", url_prefix="/api")


def _is_localhost_request(request: Request) -> bool:
    """Check if the request originates from localhost.

    Args:
        request: The incoming request

    Returns:
        True if the request is from localhost, False otherwise
    """
    # Check the actual client IP
    client_ip = request.ip
    localhost_ips = {"127.0.0.1", "::1", "localhost"}
    return client_ip in localhost_ips


def setup_project_generator(project_folder: str) -> ProjectGenerator:
    """Initialize and return a ProjectGenerator instance."""
    # Ensure the project folder is in sys.path
    if project_folder not in sys.path:
        sys.path.insert(0, project_folder)

    structlogger.info(
        "bot_builder_service.service_initialized", project_folder=project_folder
    )

    return ProjectGenerator(project_folder)


def get_project_generator(request: Request) -> ProjectGenerator:
    """Get the project generator from app context."""
    return request.app.ctx.project_generator


def get_input_channel(request: Request) -> StudioChatInput:
    """Get the input channel from app context."""
    return request.app.ctx.input_channel


async def extract_bot_data_from_agent(agent: Agent) -> BotData:
    """Extract BotData from an Agent.

    Args:
        agent: The agent to extract data from

    Returns:
        BotData containing flows, domain, config, endpoints, and nlu data
    """
    domain = agent.domain.as_dict() if agent.domain else {}
    flows = (
        await agent.processor.get_flows()
        if agent.processor
        else FlowsList(underlying_flows=[])
    )
    return BotData(
        flows=get_flows_as_json(flows),
        domain=extract_values(domain, DOMAIN_KEYS),
    )


async def get_agent_status(request: Request) -> AgentStatus:
    """Get the status of the agent."""
    if request.app.ctx.agent is None:
        return AgentStatus.not_loaded
    agent: Agent = request.app.ctx.agent
    if agent.is_ready():
        return AgentStatus.ready
    return AgentStatus.not_ready


# Health check endpoint
@bp.route("/", methods=["GET"])
@openapi.summary("Health check endpoint")
@openapi.description(
    "Returns the health status of the Bot Builder service including version "
    "information and authentication requirements"
)
@openapi.tag("health")
@openapi.response(
    200,
    {
        "application/json": {
            "status": str,
            "service": str,
            "rasa_version": str,
            "auth_required": bool,
            "agent_status": str,
        }
    },
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
async def health(request: Request) -> HTTPResponse:
    """Health check endpoint."""
    project_generator = get_project_generator(request)
    return response.json(
        {
            "status": "ok",
            "service": "bot-builder",
            "rasa_version": rasa.__version__,
            "agent_status": await get_agent_status(request),
            "auth_required": is_auth_required_now(
                project_info=project_generator.project_info
            ),
        }
    )


@bp.route("/internal/reload-agent", methods=["POST"])
async def reload_agent_internal(request: Request) -> HTTPResponse:
    """Internal endpoint to reload the agent after MCP training.

    This endpoint is protected and can only be called from localhost.
    It's used by the MCP server to notify the main Sanic server
    to reload the agent after successful training.
    """
    # Security: Only allow requests from localhost
    if not _is_localhost_request(request):
        structlogger.warning(
            "builder.service.reload_agent_internal.forbidden",
            event_info="Reload agent request from non-localhost rejected",
            client_ip=request.ip,
        )
        return response.json(
            ApiErrorResponse(
                error="Forbidden",
                details={"message": "This endpoint is only accessible from localhost"},
            ).model_dump(),
            status=HTTPStatus.FORBIDDEN,
        )

    try:
        project_generator = get_project_generator(request)

        agent = await try_load_existing_agent(project_generator.project_folder)
        if agent:
            update_agent(agent, request.app)
            structlogger.info(
                "builder.service.reload_agent_internal.success",
                event_info="Agent reloaded successfully via internal endpoint",
            )
            return response.json({"success": True, "message": "Agent reloaded"})

        structlogger.warning(
            "builder.service.reload_agent_internal.no_agent",
            event_info="No agent found to reload",
        )
        return response.json(
            {"success": False, "message": "No agent found"},
            status=HTTPStatus.NOT_FOUND,
        )

    except Exception as exc:
        capture_exception_with_context(
            exc,
            "builder.service.reload_agent_internal.error",
            tags={"endpoint": "/api/internal/reload-agent"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to reload agent",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/internal/tracker/<session_id>", methods=["GET"])
async def get_tracker_internal(request: Request, session_id: str) -> HTTPResponse:
    """Internal endpoint to get tracker context for a session.

    This endpoint is protected and can only be called from localhost.
    It's used by the MCP server to fetch conversation context after
    sending test messages to the assistant.
    """
    # Security: Only allow requests from localhost
    if not _is_localhost_request(request):
        structlogger.warning(
            "builder.service.get_tracker_internal.forbidden",
            event_info="Get tracker request from non-localhost rejected",
            client_ip=request.ip,
        )
        return response.json(
            ApiErrorResponse(
                error="Forbidden",
                details={"message": "This endpoint is only accessible from localhost"},
            ).model_dump(),
            status=HTTPStatus.FORBIDDEN,
        )

    try:
        from rasa.builder.shared.tracker_context import TrackerContext

        agent: Optional[Agent] = request.app.ctx.agent
        if not agent or not agent.is_ready():
            return response.json(
                ApiErrorResponse(
                    error=AGENT_NOT_READY_ERROR,
                    details={"message": "No agent loaded or agent not ready"},
                ).model_dump(),
                status=HTTPStatus.CONFLICT,
            )

        tracker = await agent.tracker_store.retrieve(session_id)
        if tracker is None:
            return response.json(
                ApiErrorResponse(
                    error="Tracker not found",
                    details={"session_id": session_id},
                ).model_dump(),
                status=HTTPStatus.NOT_FOUND,
            )

        tracker_context = TrackerContext.from_tracker(tracker, max_turns=50)
        if tracker_context is None:
            return response.json(
                {"conversation_turns": [], "current_state": {}},
            )

        return response.json(tracker_context.model_dump())

    except Exception as exc:
        capture_exception_with_context(
            exc,
            "builder.service.get_tracker_internal.error",
            tags={"endpoint": "/api/internal/tracker/<session_id>"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to get tracker",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/job-events/<job_id>", methods=["GET"])
@openapi.summary("Stream job progress events")
@openapi.description(
    "Stream server-sent events (SSE) tracking real-time job progress.\n\n"
    "**Connect with:** `Accept: text/event-stream`.\n\n"
    "**SSE Event Example:**\n"
    "```text\n"
    "event: received\n"
    'data: {"status": "received"}\n'
    "```\n\n"
)
@openapi.tag("job-events")
@openapi.parameter(
    "job_id", str, location="path", description="The id of the job to stream events for"
)
@openapi.response(
    200,
    {"text/event-stream": str},
    description="Server-sent events stream. See documentation for event types.",
    example=(
        "event: received\n"
        'data: {"status": "received"}\n'
        "\n"
        "event: generating\n"
        'data: {"status": "generating"}\n'
    ),
)
@openapi.response(
    404,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Unknown job_id: No such job exists.",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def job_events(request: Request, job_id: str) -> HTTPResponse:
    try:
        job = job_manager.get_job(job_id)
        if job is None:
            return response.json(
                ApiErrorResponse(
                    error="Job not found", details={"job_id": job_id}
                ).model_dump(),
                status=404,
            )

        stream = await request.respond(content_type="text/event-stream")

        try:
            async for evt in job.event_stream():
                await stream.send(evt.format())
        except Exception as exc:
            # Handle exceptions within the SSE stream context
            capture_exception_with_context(
                exc,
                "bot_builder_service.job_events.streaming_error",
                extra={"job_id": job_id},
                tags={"endpoint": "/api/job-events/<job_id>"},
            )
            # Send error event in SSE format instead of JSON response
            error_event = JobStatusEvent.from_status(
                status=JobStatus.error.value,
                message=f"Failed to stream job events: {exc}",
            ).format()
            await stream.send(error_event)
        finally:
            await stream.eof()

        return stream
    except Exception as exc:
        # This exception handler only applies before stream.respond() is called
        capture_exception_with_context(
            exc,
            "bot_builder_service.job_events.unexpected_error",
            extra={"job_id": job_id},
            tags={"endpoint": "/api/job-events/<job_id>"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to stream job events", details={"error": str(exc)}
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/prompt-to-bot", methods=["POST"])
@openapi.summary("Generate bot from natural language prompt")
@openapi.description(
    "Creates a complete conversational AI bot from a natural language prompt. "
    "Returns immediately with a job ID. Connect to `/job-events/<job_id>` to "
    "receive server-sent events (SSE) for real-time progress tracking "
    "throughout the bot creation process.\n\n"
    "**SSE Event Flow** (via `/job-events/<job_id>`):\n"
    "1. `received` - Request received by server\n"
    "2. `generating` - Generating bot project files\n"
    "3. `generation_success` - Bot generation completed successfully\n"
    "4. `training` - Training the bot model\n"
    "5. `train_success` - Model training completed\n"
    "6. `done` - Bot creation completed\n\n"
    "**Error Events:**\n"
    "- `generation_error` - Failed to generate bot from prompt\n"
    "- `train_error` - Bot generated but training failed\n"
    "- `validation_error` - Generated bot configuration is invalid\n"
    "- `error` - Unexpected error occurred\n\n"
    "**Usage:**\n"
    "1. Send POST request with Content-Type: application/json\n"
    "2. The response will be a JSON object `{job_id: ...}`\n"
    "3. Connect to `/job-events/<job_id>` for a server-sent event stream of progress."
)
@openapi.tag("bot-generation")
@openapi.body(
    {"application/json": model_to_schema(PromptRequest)},
    description="Prompt request with natural language description.",
    required=True,
    example={
        "prompt": (
            "Create a customer support bot that can help users with order inquiries, "
            "product questions, and returns processing. The bot should be friendly "
            "and able to escalate to human agents when needed."
        )
    },
)
@openapi.response(
    200,
    {"application/json": model_to_schema(JobCreateResponse)},
    description="Job created. Poll or subscribe to /job-events/<job_id> for progress.",
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Validation error in request payload",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
@observe(capture_input=False, capture_output=False)
async def handle_prompt_to_bot(request: Request) -> HTTPResponse:
    """Handle prompt-to-bot generation requests."""
    try:
        payload = PromptRequest(**request.json)
    except Exception as exc:
        return response.json(
            ApiErrorResponse(
                error="Invalid request", details={"error": str(exc)}
            ).model_dump(),
            status=400,
        )

    try:
        # Allocate job and schedule background task
        job = job_manager.create_job()
        request.app.add_task(
            run_prompt_to_bot_job(
                request.app, job, payload.prompt, request.headers.get(HEADER_USER_ID)
            )
        )
        return response.json(JobCreateResponse(job_id=job.id).model_dump(), status=200)
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.prompt_to_bot.unexpected_error",
            tags={"endpoint": "/api/prompt-to-bot"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to create prompt-to-bot job",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/template-to-bot", methods=["POST"])
@openapi.summary("Generate bot from predefined template")
@openapi.description(
    "Creates a complete conversational AI bot from a predefined template. "
    "Returns immediately with a job ID. Connect to `/job-events/<job_id>` to "
    "receive server-sent events (SSE) for real-time progress tracking "
    "throughout the bot creation process.\n\n"
    "**SSE Event Flow** (via `/job-events/<job_id>`):\n"
    "1. `received` - Request received by server\n"
    "2. `generating` - Initializing bot from template\n"
    "3. `generation_success` - Template initialization completed successfully\n"
    "4. `training` - Training the bot model\n"
    "5. `train_success` - Model training completed\n"
    "6. `done` - Bot creation completed\n\n"
    "**Error Events:**\n"
    "- `generation_error` - Failed to initialize bot from template\n"
    "- `train_error` - Template loaded but training failed\n"
    "- `validation_error` - Template configuration is invalid\n"
    "- `error` - Unexpected error occurred\n\n"
    "**Usage:**\n"
    "1. Send POST request with Content-Type: application/json\n"
    "2. The response will be a JSON object `{job_id: ...}`\n"
    "3. Connect to `/job-events/<job_id>` for a server-sent event stream of progress."
)
@openapi.tag("bot-generation")
@openapi.body(
    {"application/json": model_to_schema(TemplateRequest)},
    description="Template request with template name.",
    required=True,
    example={"template_name": "telco"},
)
@openapi.response(
    200,
    {"application/json": model_to_schema(JobCreateResponse)},
    description="Job created. Poll or subscribe to /job-events/<job_id> for progress.",
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Validation error in request payload or invalid template name",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def handle_template_to_bot(request: Request) -> HTTPResponse:
    """Create a new template-to-bot job and return job_id immediately."""
    try:
        template_data = TemplateRequest(**request.json)
    except Exception as exc:
        return response.json(
            ApiErrorResponse(
                error="Invalid request", details={"error": str(exc)}
            ).model_dump(),
            status=400,
        )

    try:
        # allocate job and schedule background task
        job = job_manager.create_job()
        request.app.add_task(
            run_template_to_bot_job(request.app, job, template_data.template_name)
        )
        return response.json(JobCreateResponse(job_id=job.id).model_dump(), status=200)
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.template_to_bot.unexpected_error",
            tags={"endpoint": "/api/template-to-bot"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to create template-to-bot job",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/github-to-bot", methods=["POST"])
@openapi.summary("Initialize bot from public GitHub repository")
@openapi.description(
    "Clones a public GitHub repository and initializes the bot from its contents. "
    "Returns immediately with a job ID. Connect to `/job-events/<job_id>` to "
    "receive server-sent events (SSE) for real-time progress tracking.\n\n"
    "**SSE Event Flow** (via `/job-events/<job_id>`):\n"
    "1. `received` - Request received by server\n"
    "2. `cloning` - Cloning the GitHub repository\n"
    "3. `clone_success` - Repository cloned successfully\n"
    "4. `validating` - Validating bot configuration\n"
    "5. `validation_success` - Validation passed\n"
    "6. `training` - Training the bot model\n"
    "7. `train_success` - Model training completed\n"
    "8. `done` - Bot initialization completed\n\n"
    "**Error Events:**\n"
    "- `clone_error` - Failed to clone repository\n"
    "- `train_error` - Repository cloned but training failed\n"
    "- `validation_error` - Repository cloned but configuration is invalid\n"
    "- `error` - Unexpected error occurred\n\n"
    "**Note:** This endpoint only supports public GitHub repositories. "
    "Private repositories are not supported."
)
@openapi.tag("bot-generation")
@openapi.body(
    {"application/json": model_to_schema(GitHubToBotRequest)},
    description="GitHub repository details.",
    required=True,
    example={
        "repo_url": "https://github.com/myorg/my-bot.git",
        "branch": "main",
    },
)
@openapi.response(
    200,
    {"application/json": model_to_schema(JobCreateResponse)},
    description="Job created. Poll or subscribe to /job-events/<job_id> for progress.",
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Validation error in request payload",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def handle_github_to_bot(request: Request) -> HTTPResponse:
    """Initialize bot from a public GitHub repository."""
    try:
        payload = GitHubToBotRequest(**request.json)
    except Exception as exc:
        return response.json(
            ApiErrorResponse(
                error="Invalid request", details={"error": str(exc)}
            ).model_dump(),
            status=400,
        )

    try:
        job = job_manager.create_job()
        request.app.add_task(
            run_github_to_bot_job(
                app=request.app,
                job=job,
                repo_url=payload.repo_url,
                branch=payload.branch,
            )
        )
        return response.json(JobCreateResponse(job_id=job.id).model_dump(), status=200)
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.github_to_bot.unexpected_error",
            tags={"endpoint": "/api/github-to-bot"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to create github-to-bot job",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/backup-to-bot", methods=["POST"])
@openapi.summary("Generate bot from backup archive")
@openapi.description(
    "Creates a complete conversational AI bot from a backup tar.gz archive. "
    "Returns immediately with a job ID. Connect to `/job-events/<job_id>` to "
    "receive server-sent events (SSE) for real-time progress tracking "
    "throughout the bot restoration process.\n\n"
    "**SSE Event Flow** (via `/job-events/<job_id>`):\n"
    "1. `received` - Request received by server\n"
    "2. `generating` - Extracting and restoring bot from backup\n"
    "3. `generation_success` - Backup restoration completed successfully\n"
    "4. `training` - Training the bot model (if no existing model found)\n"
    "5. `train_success` - Model training completed (if training was needed)\n"
    "6. `done` - Bot restoration completed\n\n"
    "**Error Events:**\n"
    "- `generation_error` - Failed to restore bot from backup\n"
    "- `train_error` - Backup restored but training failed\n"
    "- `validation_error` - Restored bot configuration is invalid\n"
    "- `error` - Unexpected error occurred\n\n"
    "**Usage:**\n"
    "1. Send POST request with Content-Type: application/json\n"
    "2. The response will be a JSON object `{job_id: ...}`\n"
    "3. Connect to `/job-events/<job_id>` for a server-sent event stream of progress."
)
@openapi.tag("bot-generation")
@openapi.body(
    {"application/json": model_to_schema(RestoreFromBackupRequest)},
    description="Backup request with presigned URL to tar.gz archive.",
    required=True,
    example={"presigned_url": "https://s3.amazonaws.com/bucket/path?signature=..."},
)
@openapi.response(
    200,
    {"application/json": model_to_schema(JobCreateResponse)},
    description="Job created. Poll or subscribe to /job-events/<job_id> for progress.",
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Validation error in request payload or invalid presigned URL",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def handle_backup_to_bot(request: Request) -> HTTPResponse:
    """Handle backup-to-bot restoration requests."""
    try:
        payload = RestoreFromBackupRequest(**request.json)
    except Exception as exc:
        return response.json(
            ApiErrorResponse(
                error="Invalid request", details={"error": str(exc)}
            ).model_dump(),
            status=400,
        )

    try:
        # Allocate job and schedule background task
        job = job_manager.create_job()
        request.app.add_task(
            run_backup_to_bot_job(request.app, job, payload.presigned_url)
        )
        return response.json(JobCreateResponse(job_id=job.id).model_dump(), status=200)
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.backup_to_bot.unexpected_error",
            tags={"endpoint": "/api/backup-to-bot"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to create backup-to-bot job",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/files", methods=["GET"])
@openapi.summary("Get bot files")
@openapi.description(
    "Retrieves the current bot configuration files including domain.yml, "
    "config.yml, flows.yml, NLU data, and other project files as a "
    "dictionary mapping file names to their string contents. "
    "Binary files are included in the response with `content: null`."
)
@openapi.tag("bot-files")
@openapi.response(
    200,
    {"application/json": {str: Optional[str]}},
    description="Bot files retrieved successfully",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def get_bot_files(request: Request) -> HTTPResponse:
    """Get current bot files."""
    try:
        project_generator = get_project_generator(request)
        bot_files = project_generator.get_bot_files()
        return response.json(bot_files)
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.get_bot_files.unexpected_error",
            tags={"endpoint": "/api/files", "method": "GET"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to retrieve bot files", details={"error": str(exc)}
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/files", methods=["POST"])
@openapi.summary("Replace all bot files")
@openapi.description(
    "Replaces all bot configuration files with the provided files, deletes any "
    "files not included in the request (excluding .rasa/ and models/ directories), "
    "and retrains the model. Returns immediately with a job ID. Connect to "
    "`/job-events/<job_id>` for real-time SSE progress tracking."
    "\n\n"
    "**File Management:**\n"
    "- All files in the request are written to the project folder\n"
    "- Files not included in the request are deleted from the project\n"
    "- Files/folders starting with `.rasa/` or `models/` are excluded from deletion\n\n"
    "**Binary Files:**\n"
    "- Binary files appear in GET response with `content: null`\n"
    "- To preserve a binary file: include it with `content: null`\n"
    "- To delete a binary file: omit it from the request\n"
    "- Binary files cannot be created, renamed, or modified through this endpoint\n"
    "- Sending `content: null` for text or non-existent files returns an error\n"
    '- Use empty string (`""`) if you want an empty text file\n\n'
    "**SSE Event Flow:** (available via /job-events/<job_id>)\n"
    "1. `received` - Request received by server\n"
    "2. `validating` - Validating bot configuration files\n"
    "3. `validation_success` - File validation completed successfully\n"
    "4. `training` - Training the bot model with updated files\n"
    "5. `train_success` - Model training completed\n"
    "6. `done` - Bot files replacement completed\n\n"
    "**Error Events (can occur at any time):**\n"
    "- `validation_error` - Bot configuration files are invalid\n"
    "- `train_error` - Files updated but training failed\n"
    "- `copilot_analysis_start` - Copilot analysis started (includes `copilot_job_id` "
    "in payload)\n"
    "- `error` - Unexpected error occurred\n\n"
    "**Copilot Analysis Events:**\n"
    "When training or validation fails, a separate copilot analysis job is "
    "automatically started. The `copilot_analysis_start` event includes a "
    "`copilot_job_id` in the payload.\nConnect to `/job-events/<copilot_job_id>` to "
    "receive the following events:\n"
    "- `copilot_analyzing` - Copilot is analyzing errors and providing suggestions. "
    "Uses the same SSE event payload format as the `/copilot` endpoint with `content`, "
    "`response_category`, and `completeness` fields.\n"
    "- `copilot_analysis_success` - Copilot analysis completed with references.\n"
    "- `copilot_analysis_error` - Copilot analysis failed\n\n"
    "**Usage:**\n"
    "1. Send POST request with Content-Type: application/json\n"
    "2. The response will be a JSON object `{job_id: ...}`\n"
    "3. Connect to `/job-events/<job_id>` for a server-sent event stream of progress."
)
@openapi.tag("bot-files")
@openapi.body(
    {"application/json": {str: Optional[str]}},
    description=(
        "A dictionary mapping file names to their complete content. "
        "All files in the project will be replaced with these files. "
        "Files not included in the request will be deleted from the project "
        "(except for .rasa/ and models/ directories). "
        "The file name should be the relative path from the project root."
    ),
    required=True,
    example={
        "domain.yml": (
            "version: '3.1'\n"
            "intents:\n  - greet\n  - goodbye\n"
            "responses:\n  utter_greet:\n  - text: 'Hello!'\n"
            "  utter_goodbye:\n  - text: 'Goodbye!'"
        ),
        "config.yml": (
            "version: '3.1'\n"
            "pipeline:\n  - name: WhitespaceTokenizer\n"
            "  - name: RegexFeaturizer\n  - name: LexicalSyntacticFeaturizer\n"
            "  - name: CountVectorsFeaturizer\n"
            "policies:\n  - name: MemoizationPolicy\n  - name: RulePolicy"
        ),
        "data/nlu.yml": (
            "version: '3.1'\n"
            "nlu:\n- intent: greet\n  examples: |\n    - hello\n    - hi"
        ),
    },
)
@openapi.response(
    200,
    {"application/json": model_to_schema(JobCreateResponse)},
    description=(
        "Job created. Poll or subscribe to /job-events/<job_id> "
        "for progress and SSE updates on file replacement and training."
    ),
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Validation error in bot files",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.response(
    401,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description=(
        "Authentication failed - Authorization header missing or invalid. "
        "Auth may be conditionally required depending on server "
        "configuration."
    ),
)
@openapi.parameter(
    "Authorization",
    description=(
        "Bearer token for authentication. Required after auth start window "
        "or if configured."
    ),
    _in="header",
    required=False,
    schema=str,
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
@openapi.parameter(
    "X-Commit-Message",
    description=(
        "Optional custom commit message. If not provided, AI-generated message used."
    ),
    _in="header",
    required=False,
    schema=str,
)
@protected()
async def replace_all_bot_files(request: Request) -> HTTPResponse:
    """Replace all bot files with server-sent events for progress tracking."""
    try:
        bot_files = request.json
    except Exception as exc:
        return response.json(
            ApiErrorResponse(
                error="Invalid request", details={"error": str(exc)}
            ).model_dump(),
            status=400,
        )

    try:
        # Extract commit metadata from headers
        commit_message = request.headers.get("X-Commit-Message")
        email = email_from_auth(request) or DEFAULT_BOT_BUILDER_EMAIL
        commit_info = GitCommitInfo(
            author="Bot Builder", email=email, message=commit_message
        )

        job = job_manager.create_job()
        request.app.add_task(
            run_replace_all_files_job(request.app, job, bot_files, commit_info)
        )
        return response.json(JobCreateResponse(job_id=job.id).model_dump(), status=200)
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.replace_all_bot_files.unexpected_error",
            tags={"endpoint": "/api/files", "method": "POST"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to replace bot files", details={"error": str(exc)}
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/data", methods=["GET"])
@openapi.summary("Get bot data")
@openapi.description(
    "Retrieves the current bot data in CALM import format with flows, domain, "
    "config, endpoints, and NLU data"
)
@openapi.tag("bot-info")
@openapi.response(
    200,
    {"application/json": model_to_schema(BotData)},
    description="Bot data retrieved successfully",
)
@openapi.response(
    409,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description=AGENT_NOT_READY_ERROR,
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def get_bot_data(request: Request) -> HTTPResponse:
    """Get current bot data in CALM import format."""
    try:
        agent: Optional[Agent] = request.app.ctx.agent
        if not agent:
            raise AgentNotReady(
                "Can't retrieve the data without an agent being loaded."
            )

        bot_data = await extract_bot_data_from_agent(agent)

        return response.json(bot_data.model_dump())
    except AgentNotReady as e:
        return response.json(
            ApiErrorResponse(
                error=AGENT_NOT_READY_ERROR,
                details={"error": str(e)},
            ).model_dump(),
            status=HTTPStatus.CONFLICT,
        )
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.get_bot_data.unexpected_error",
            tags={"endpoint": "/api/data"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to retrieve bot data",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/assistant", methods=["GET"])
@openapi.summary("Get assistant info")
@openapi.description(
    "Returns basic information about the loaded assistant, including the assistant id "
    "as configured in the model's metadata (from config.yml)."
)
@openapi.tag("bot-info")
@openapi.response(
    200,
    {"application/json": model_to_schema(AssistantInfo)},
    description="Assistant info retrieved successfully",
)
@openapi.response(
    409,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description=AGENT_NOT_READY_ERROR,
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def get_bot_info(request: Request) -> HTTPResponse:
    """Return assistant info including assistant id from model metadata."""
    try:
        agent: Optional[Agent] = request.app.ctx.agent
        if not agent:
            raise AgentNotReady("Can't retrieve bot info without a loaded agent.")

        assistant_id: Optional[str] = (
            agent.processor.model_metadata.assistant_id
            if agent.processor
            and agent.processor.model_metadata
            and hasattr(agent.processor.model_metadata, "assistant_id")
            else None
        )

        return response.json(AssistantInfo(assistant_id=assistant_id).model_dump())
    except AgentNotReady as e:
        return response.json(
            ApiErrorResponse(
                error=AGENT_NOT_READY_ERROR,
                details={"error": str(e)},
            ).model_dump(),
            status=HTTPStatus.CONFLICT,
        )
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.get_bot_info.unexpected_error",
            tags={"endpoint": "/api/assistant"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to retrieve bot info",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/download", methods=["GET"])
@openapi.summary("Download bot project as tar.gz")
@openapi.description(
    "Downloads the current bot project files as a compressed tar.gz archive. "
    "Includes all configuration files and a .env file with RASA_PRO_LICENSE."
)
@openapi.tag("bot-files")
@openapi.parameter(
    "exclude_models_directory",
    bool,
    location="query",
    description="Whether to exclude the models directory",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
@openapi.parameter(
    "project_name",
    description="Name of the project for the archive filename and pyproject.toml",
    _in="query",
    required=False,
    schema=str,
)
@openapi.response(
    200,
    {"application/gzip": bytes},
    description="Bot project downloaded successfully as tar.gz",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
async def download_bot_project(request: Request) -> HTTPResponse:
    """Download bot project as tar.gz archive."""
    try:
        # Get query parameters
        exclude_models_directory = (
            request.args.get("exclude_models_directory", "true").lower() == "true"
        )
        project_name = request.args.get("project_name", "bot-project")

        # Get bot files
        project_generator = get_project_generator(request)
        bot_files = project_generator.get_bot_files(
            exclude_models_directory=exclude_models_directory
        )

        # Create tar.gz archive
        tar_data = create_bot_project_archive(
            bot_files, project_name, project_generator.project_folder
        )

        structlogger.info(
            "bot_builder_service.download_bot_project.success",
            user_sub=(getattr(request.ctx, "auth_payload", None) or {}).get("sub"),
            files_count=len(bot_files),
            archive_size=len(tar_data),
            payload=getattr(request.ctx, "auth_payload", None),
            project_name=project_name,
        )

        return response.raw(
            tar_data,
            content_type="application/gzip",
            headers={
                "Content-Disposition": f"attachment; filename={project_name}.tar.gz"
            },
        )

    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.download_bot_project.unexpected_error",
            tags={"endpoint": "/api/download"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to create bot project archive",
                details={"error": str(exc)},
            ).model_dump(),
            status=500,
        )


@bp.route("/copilot", methods=["POST"])
@openapi.summary("AI copilot for bot building")
@openapi.description(
    "Provides LLM-powered copilot assistance with streaming markdown responses. "
    "Returns server-sent events (SSE) for real-time streaming of copilot responses.\n\n"
    "The event's `event` field is the type of event. For this endpoint it will always "
    "be: `copilot_response`.\n\n"
)
@openapi.tag("copilot")
@openapi.body(
    {"application/json": model_to_schema(CopilotTurnRequest)},
    description=(
        "Copilot request containing: "
        "1. a single user message, "
        "2. session ID for tracking conversation context with the bot being built, "
        "3. optional chat ID for copilot conversation (defaults to 'default'), "
        "4. optional project ID for conversation isolation, "
        "Conversation history is stored and managed on the server."
    ),
    required=True,
)
@openapi.response(
    200,
    {"text/event-stream": model_to_schema(ServerSentEvent)},
    description=(
        "Server-sent events stream with copilot responses and references. "
        "The event's `event` field is the type "
        "of event. For this endpoint it will always be: `copilot_response`. "
        "The event's data field is a JSON dump. The following describes the event data "
        "field:\n"
        "- `response_category` can be one of the following:\n"
        "  - `copilot` - Stream token generated by the copilot.\n"
        "  - `out_of_scope_detection` - Response coming from the the out of scope detection.\n"  # noqa: E501
        "  - `roleplay_detection` - Response coming from the the roleplay detection.\n"
        "  - `reference` - Reference section.\n"
        "  - `training_error_log_analysis` - Used to flag the responses that are training error log analysis.\n"  # noqa: E501
        "  - `e2e_testing_error_log_analysis` - Used to flag the responses that are e2e testing error log analysis.\n"  # noqa: E501
        "  - `guardrail_policy_violation` - Used to flag the responses that are flagged as a violation of the guardrail policy.\n\n"  # noqa: E501
        "- `completeness`: Whether this is a streaming token or complete response.\n\n"
        "- `content`: The actual response content (for streaming tokens).\n\n"
        "- `references`: (Only for `reference` response category) List of reference entries. Each reference entry is a dictionary with the following keys:\n\n"  # noqa: E501
        "   - `index`: Reference index as an integer number.\n"
        "   - `title`: Reference title as a string.\n"
        "   - `url`: Reference URL as a string.\n\n"
    ),
    examples={
        "Streaming token from copilot": {
            "summary": "Streaming token from copilot",
            "value": GeneratedContent(
                content="<token generated by the copilot>",
                response_category=ResponseCategory.COPILOT,
                response_completeness=ResponseCompleteness.TOKEN,
            )
            .to_sse_event()
            .model_dump(),
        },
        "Complete response for the following response categories: out_of_scope_detection / roleplay_detection / training_error_log_analysis / e2e_testing_error_log_analysis / guardrail_policy_violation": {  # noqa: E501
            "summary": "Complete response from copilot",
            "value": GeneratedContent(
                content="<response from the out of scope detection>",
                response_category=ResponseCategory.OUT_OF_SCOPE_DETECTION,
                response_completeness=ResponseCompleteness.COMPLETE,
            )
            .to_sse_event()
            .model_dump(),
        },
        "Response with the references": {
            "summary": "Reference section with all references",
            "value": ReferenceSection(
                references=[
                    ReferenceEntry(
                        index=1,
                        title="Title of the reference",
                        url="https://rasa.com/docs/...",
                    ),
                    ReferenceEntry(
                        index=2,
                        title="Title of another reference",
                        url="https://rasa.com/docs/...",
                    ),
                ],
                response_category=ResponseCategory.REFERENCE,
                response_completeness=ResponseCompleteness.COMPLETE,
            )
            .to_sse_event()
            .model_dump(),
        },
    },
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Validation error in request",
)
@openapi.response(
    502,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="LLM generation failed.",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error.",
)
@openapi.response(
    401,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description=(
        "Authentication failed - Authorization header missing or invalid. "
        "Auth may be conditionally required depending on server "
        "configuration."
    ),
)
@openapi.parameter(
    "Authorization",
    description=(
        "Bearer token for authentication. Required after auth start window "
        "or if configured."
    ),
    _in="header",
    required=False,
    schema=str,
)
@openapi.parameter(
    HEADER_USER_ID,
    description=("Required user id used for telemetry and guardrails (X-User-Id)."),
    _in="header",
    required=True,
    schema=str,
)
@protected()
# Disable automatic input/output capture for langfuse tracing
# This allows manual control over what data is sent to langfuse
@observe(capture_input=False, capture_output=False)
async def copilot(request: Request) -> None:
    """Handle copilot requests with streaming markdown responses."""
    from rasa.builder.copilot import get_copilot_mode as _get_copilot_mode

    sse = await request.respond(content_type="text/event-stream")
    project_generator = get_project_generator(request)

    req: Optional[CopilotTurnRequest] = None
    try:
        # 1. Validate and unpack input
        req = CopilotTurnRequest(**request.json)

        # Require user identifier via header and fail fast if missing
        user_id = (request.headers.get(HEADER_USER_ID) or "").strip()
        if not user_id:
            structlogger.error(
                "bot_builder_service.copilot.missing_or_empty_user_id_header",
                event_info=f"Missing or empty required header: {HEADER_USER_ID}",
            )
            await sse.send(
                ServerSentEvent(
                    event="error",
                    data={
                        "error": f"Missing or empty required header: {HEADER_USER_ID}"
                    },
                ).format()
            )
            return

        telemetry = CopilotSegmentTelemetry(
            project_id=HELLO_RASA_PROJECT_ID, user_id=user_id
        )
        structlogger.debug("builder.copilot.telemetry.request.init")

        # TODO: This can be removed once Langfuse is completed.
        if req.message and req.message.role == ROLE_USER:
            structlogger.debug("builder.copilot.telemetry.request.user_turn")
            # Offload telemetry logging to a background task
            request.app.add_task(
                asyncio.to_thread(
                    telemetry.log_user_turn,
                    req.message.get_flattened_text_content(),
                )
            )

        # 2. Check if we need to block the request due to too many guardrails violations
        if (scope := await _get_copilot_block_scope(user_id)) is not None:
            from rasa.builder.copilot.response_handling import (
                base_copilot_response_handler as handler_module,
            )

            message = (
                handler_module.BaseCopilotResponseHandler.respond_to_guardrail_blocked(
                    scope
                )
            )
            await sse.send(message.to_sse_event().format())
            return

        # 3. Load existing server-side history and build candidate turn
        # Use chat_id from payload if provided, otherwise use default
        chat_id = (req.chat_id or "").strip() or DEFAULT_COPILOT_CHAT_ID
        conversation_key = ConversationKey(chat_id=chat_id)
        existing_history = await llm_service.history_store.get(conversation_key)

        # Build the full history for context (existing + new user message)
        candidate_history = [*existing_history, req.message]

        # 4. Get the necessary context for the copilot
        tracker_context = await get_tracker_context_for_copilot(request, req, user_id)
        relevant_assistant_files = get_relevant_assistant_files_for_copilot(
            project_generator,
        )
        context = CopilotContext(
            tracker_context=tracker_context,
            assistant_logs=get_recent_logs(),
            assistant_files=relevant_assistant_files,
            copilot_chat_history=candidate_history,
        )

        # Persist the user message to history
        try:
            await llm_service.history_store.append(conversation_key, req.message)
        except Exception as exc:
            structlogger.error(
                "builder.copilot.history.user_message_persist_failed", error=str(exc)
            )

        # 5. Run guardrail policy checks. If any policy violations are detected,
        #    send a response and end the stream.
        guardrail_response: Optional[GuardrailPolicyViolationContent] = None
        if llm_service.guardrails_policy_checker is not None:
            guardrail_response = await llm_service.guardrails_policy_checker.check_copilot_chat_for_policy_violations(  # noqa: E501
                context=context,
                hello_rasa_user_id=user_id,
                hello_rasa_project_id=HELLO_RASA_PROJECT_ID,
                lakera_project_id=LAKERA_COPILOT_HISTORY_GUARDRAIL_PROJECT_ID,
            )
        if guardrail_response is not None:
            blocked_or_violation_message = (
                await _handle_guardrail_violation_and_maybe_block(
                    sse=sse,
                    user_id=user_id,
                    violation_response=guardrail_response,
                )
            )

            # Persist the guardrail response as well
            guardrail_chat_message = CopilotChatMessage(
                role="copilot",
                content=[
                    TextContent(type="text", text=blocked_or_violation_message.content)
                ],
                response_category=blocked_or_violation_message.response_category,
            )
            try:
                await llm_service.history_store.append(
                    conversation_key, guardrail_chat_message
                )
            except Exception as exc:
                structlogger.error(
                    "builder.copilot.history.guardrail_response_persist_failed",
                    error=str(exc),
                )
            return

        # 6. Get the original response stream from copilot and handle it with the
        #    copilot response handler
        start_timestamp = time.perf_counter()

        async with project_generator.git_service.git_operation():
            copilot_class = get_copilot_class()
            copilot_client = copilot_class()
            (
                copilot_response_handler,
                generation_context,
            ) = await copilot_client.generate_response(context)

            # 7. Stream the intercepted response
            async for token in copilot_response_handler.stream():
                await sse.send(token.to_sse_event().format())

            commit_prior_to_changes = (
                await project_generator.git_service.get_current_commit_sha()
            )
            commit_info = DEFAULT_COMMIT_INFO.model_copy(update={"message": None})
            commit_sha_after_changes = await project_generator.unsafe_commit_changes(
                commit_info
            )

            if commit_sha_after_changes != commit_prior_to_changes:
                newly_created_commit_sha = commit_sha_after_changes
            else:
                newly_created_commit_sha = None

        # 7b. Ensure training happens after file changes
        training_success = False
        if newly_created_commit_sha:
            training_success = await _ensure_training_after_copilot_commit(
                copilot_response_handler,
                project_generator,
                request.app,
                newly_created_commit_sha,
            )

        # 7c. Send commit info via SSE if a new commit was created
        commit_info_dict = None
        if newly_created_commit_sha:
            try:
                commit_event = await copilot_response_handler.respond_to_commit(
                    git_service=project_generator.git_service,
                    commit_sha=newly_created_commit_sha,
                    training_success=training_success,
                )
                commit_info_dict = commit_event.commit
                await sse.send(commit_event.to_sse_event().format())
            except Exception as exc:
                structlogger.warning(
                    "builder.copilot.send_commit_info_failed",
                    error=str(exc),
                    commit_sha=newly_created_commit_sha,
                )

        # 8a. Offload metabase telemetry logging to a background task
        usage_stats = copilot_client.usage_statistics
        request.app.add_task(
            asyncio.to_thread(
                telemetry.log_copilot_from_handler,
                handler=copilot_response_handler,
                used_documents=copilot_response_handler.retrieved_documents,
                latency_ms=int((time.perf_counter() - start_timestamp) * 1000),
                system_message=generation_context.system_message,
                chat_history=generation_context.chat_history,
                last_user_message=(
                    req.message.get_flattened_text_content()
                    if (req.message and req.message.role == ROLE_USER)
                    else None
                ),
                tracker_event_attachments=generation_context.tracker_event_attachments,
                model=usage_stats.model or "",
                cached_prompt_tokens=usage_stats.cached_prompt_tokens or 0,
                prompt_tokens=usage_stats.prompt_tokens or 0,
                completion_tokens=usage_stats.completion_tokens or 0,
                total_tokens=usage_stats.total_tokens or 0,
            )
        )
        # 8b. Setup output trace attributes for Langfuse
        CopilotEndpointLangfuseTelemetry.setup_copilot_endpoint_call_trace_attributes(
            hello_rasa_project_id=HELLO_RASA_PROJECT_ID or "N/A",
            chat_id=req.session_id or "N/A",
            user_id=user_id,
            request=req,
            handler=copilot_response_handler,
            relevant_documents=copilot_response_handler.retrieved_documents,
            copilot_context=context,
        )

        # 9. Once the stream is over, extract and send references (if any)
        reference_section = copilot_response_handler.extract_references()
        if reference_section.references:
            await sse.send(reference_section.to_sse_event().format())

        # 10. Append final assistant message to server-side history
        full_text = copilot_response_handler.extract_text_from_generated_responses()
        final_plan = copilot_response_handler.extract_final_plan()
        category = copilot_response_handler.extract_response_category()
        references = (
            reference_section.references if reference_section.references else None
        )

        if full_text:
            try:
                # Pass references directly if they exist
                await persist_copilot_message_to_history(
                    text=full_text,
                    chat_id=chat_id,
                    response_category=category,
                    references=references,
                    commit=commit_info_dict,
                    plan=final_plan,
                )
            except Exception as exc:
                structlogger.error(
                    "builder.copilot.history.persist_failed", error=str(exc)
                )
        else:
            # Warn if no text was generated to persist
            structlogger.warning(
                "builder.copilot.history.no_assistant_text",
                session_id=req.session_id,
                implementation=_get_copilot_mode(),
            )

    except CopilotStreamError as e:
        capture_exception_with_context(
            e,
            "bot_builder_service.copilot.generation_error",
            extra={"session_id": req.session_id if req is not None else None},
            tags={"endpoint": "/api/copilot"},
        )
        CopilotEndpointLangfuseTelemetry.update_trace_on_error(
            request=req,
            exc=e,
        )
        await sse.send(
            ServerSentEvent(
                event="error",
                data={"error": str(e)},
            ).format()
        )

    except asyncio.CancelledError as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.copilot.cancelled",
            extra={"session_id": req.session_id if req is not None else None},
            tags={"endpoint": "/api/copilot"},
        )
        CopilotEndpointLangfuseTelemetry.update_trace_on_error(
            request=req,
            exc=exc,
        )

    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.copilot.unexpected_error",
            extra={"session_id": req.session_id if req is not None else None},
            tags={"endpoint": "/api/copilot"},
        )
        CopilotEndpointLangfuseTelemetry.update_trace_on_error(
            request=req,
            exc=exc,
        )
        await sse.send(
            ServerSentEvent(
                event="error",
                data={"error": str(exc)},
            ).format()
        )

    finally:
        await sse.eof()


@bp.route("/copilot/internal_message_templates/<template_name>", methods=["GET"])
@openapi.summary("Get templated response for copilot internal message")
@openapi.description(
    "Returns the templated response text for a given template name from the "
    "copilot internal message formatter. This endpoint provides access to the "
    "predefined templates used for formatting internal system messages."
)
@openapi.tag("copilot")
@openapi.parameter(
    "template_name",
    str,
    location="path",
    description=(
        "The template name to get the template for."
        "(e.g., 'training_error_log_analysis', 'e2e_testing_error_log_analysis')",
    ),
)
@openapi.response(
    200,
    {"application/json": {"template": str, "template_name": str}},
    description="Successfully retrieved the template for the given template name",
    example={
        "template": "The assistant training failed. Your task is ...",
        "template_name": "training_error_log_analysis",
    },
)
@openapi.response(
    404,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Template not found for the given template name",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def get_copilot_internal_message_template(
    request: Request, template_name: str
) -> HTTPResponse:
    """Get templated response for copilot internal message formatter."""
    try:
        # Try to get the template for the given template name
        template = copilot_internal_message_templates().get(template_name)
        structlogger.info(
            "bot_builder_service.get_copilot_internal_message_template.template_found",
            template_name=template_name,
            template=template,
        )

        if template is None:
            structlogger.warning(
                "bot_builder_service.get_copilot_internal_message_template.not_found",
                template_name=template_name,
            )
            return response.json(
                ApiErrorResponse(
                    error="Template not found", details={"template_name": template_name}
                ).model_dump(),
                status=404,
            )

        return response.json({"template": template, "template_name": template_name})

    except Exception as e:
        structlogger.error(
            "bot_builder_service.get_copilot_internal_message_template.error",
            error=str(e),
            template_name=template_name,
        )
        return response.json(
            ApiErrorResponse(
                error="Internal server error", details={"template_name": template_name}
            ).model_dump(),
            status=500,
        )


@bp.route("/copilot/history", methods=["GET"])
@openapi.summary("Get Copilot chat history")
@openapi.description("Return stored copilot chat history.")
@openapi.tag("copilot")
@openapi.parameter(
    "chat_id",
    description="Optional chat ID to get history for (defaults to 'default').",
    _in="query",
    required=False,
    schema=str,
)
@openapi.response(
    200,
    {"application/json": model_to_schema(CopilotHistoryResponse)},
    description="Chat history retrieved successfully",
)
async def get_copilot_history(request: Request) -> HTTPResponse:
    """Get copilot chat history."""
    # Use chat_id from query parameter if provided, otherwise use default
    chat_id = (request.args.get("chat_id") or "").strip() or DEFAULT_COPILOT_CHAT_ID
    conversation_key = ConversationKey(chat_id=chat_id)
    messages = await llm_service.history_store.get(conversation_key)
    return response.json(CopilotHistoryResponse(messages=messages).model_dump())


@bp.route("/copilot/history", methods=["DELETE"])
@openapi.summary("Delete Copilot chat history")
@openapi.description("Delete stored copilot chat history.")
@openapi.tag("copilot")
@openapi.parameter(
    "chat_id",
    description="Optional chat ID to delete history for (defaults to 'default').",
    _in="query",
    required=False,
    schema=str,
)
@openapi.response(
    200,
    {
        "application/json": {
            "type": "object",
            "properties": {
                "status": {"type": "string"},
            },
        }
    },
    description="History deleted successfully",
)
async def delete_copilot_history(request: Request) -> HTTPResponse:
    """Delete copilot chat history."""
    # Use chat_id from query parameter if provided, otherwise use default
    chat_id = (request.args.get("chat_id") or "").strip() or DEFAULT_COPILOT_CHAT_ID
    conversation_key = ConversationKey(chat_id=chat_id)
    await llm_service.history_store.delete(conversation_key)
    return response.json({"status": "deleted"})


@bp.route("/copilot/mode", methods=["GET"])
@openapi.summary("Get current copilot mode")
@openapi.description("Returns the current copilot mode (agent_sdk or legacy).")
@openapi.tag("copilot")
@openapi.response(
    200,
    {"application/json": {"mode": str}},
    description="Current copilot mode retrieved successfully",
    example={"mode": "legacy"},
)
async def get_copilot_mode(request: Request) -> HTTPResponse:
    """Get current copilot mode."""
    from rasa.builder.copilot import get_copilot_mode as _get_copilot_mode

    return response.json({"mode": _get_copilot_mode()})


@bp.route("/copilot/mode", methods=["POST"])
@openapi.summary("Switch copilot mode")
@openapi.description(
    "Switches between AgentCopilot and LegacyCopilot implementations at runtime. "
    "No service restart required."
)
@openapi.tag("copilot")
@openapi.body(
    {"application/json": {"mode": str}},
    description="Mode to switch to: 'agent_sdk' or 'legacy'",
    required=True,
    example={"mode": "agent_sdk"},
)
@openapi.response(
    200,
    {"application/json": {"mode": str, "message": str}},
    description="Copilot mode switched successfully",
    example={"mode": "agent_sdk", "message": "Copilot mode switched to agent_sdk"},
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Invalid mode specified",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
async def switch_copilot_mode(request: Request) -> HTTPResponse:
    """Switch copilot mode at runtime."""
    try:
        if request.json is None:
            return response.json(
                ApiErrorResponse(
                    error="Invalid request",
                    details={"message": "Request body is required"},
                ).model_dump(),
                status=400,
            )

        mode = request.json.get("mode")
        if not mode:
            return response.json(
                ApiErrorResponse(
                    error="Invalid request",
                    details={"message": "Mode parameter is required"},
                ).model_dump(),
                status=400,
            )

        from rasa.builder.copilot import set_copilot_mode

        set_copilot_mode(mode)

        structlogger.info(
            "builder.service.switch_copilot_mode.success",
            event_info=f"Copilot mode switched to {mode}",
            mode=mode,
        )

        return response.json(
            {"mode": mode, "message": f"Copilot mode switched to {mode}"}
        )

    except ValueError as exc:
        return response.json(
            ApiErrorResponse(
                error="Invalid mode",
                details={"message": str(exc)},
            ).model_dump(),
            status=400,
        )
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "builder.service.switch_copilot_mode.error",
            tags={"endpoint": "/api/copilot/mode"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to switch copilot mode",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


async def current_tracker_from_input_channel(
    app: Any, session_id: str
) -> Optional[DialogueStateTracker]:
    """Generate chat bot context from current conversation."""
    if app.ctx.agent and session_id:
        return await app.ctx.agent.tracker_store.retrieve(session_id)
    else:
        return None


async def _ensure_training_after_copilot_commit(
    copilot_response_handler: Any,
    project_generator: ProjectGenerator,
    app: Any,
    commit_sha: str,
) -> bool:
    """Ensure the model reflects all file changes made by copilot.

    If training already completed after the last file write, returns True.
    Otherwise, triggers auto-training to ensure changes are reflected in the model.

    Args:
        copilot_response_handler: The response handler from the copilot session.
        project_generator: The project generator instance.
        app: The Sanic application instance.
        commit_sha: SHA of the commit that was just created.

    Returns:
        True if the model is up-to-date, False otherwise.
    """
    # Check if training completed after the last file write
    is_model_up_to_date = (
        hasattr(copilot_response_handler, "is_model_up_to_date")
        and copilot_response_handler.is_model_up_to_date
    )

    if is_model_up_to_date:
        structlogger.debug(
            "builder.copilot.training_status.model_up_to_date",
            commit_sha=commit_sha,
        )
        return True

    # Either training wasn't called, failed, or file writes happened after training
    structlogger.info(
        "builder.copilot.auto_training.starting",
        event_info="Auto-training after copilot commit",
        commit_sha=commit_sha,
    )
    try:
        training_input = project_generator.get_training_input()
        agent = await train_and_load_agent(
            training_input, role="copilot", action="auto_train"
        )
        update_agent(agent, app)
        structlogger.info(
            "builder.copilot.auto_training.success",
            commit_sha=commit_sha,
        )
        return True
    except Exception as exc:
        structlogger.error(
            "builder.copilot.auto_training.failed",
            error=str(exc),
            commit_sha=commit_sha,
        )
        return False


async def _get_copilot_block_scope(user_id: str) -> Optional[BlockScope]:
    """Return the guardrail block scope for Copilot if blocked.

    Args:
        user_id: User identifier.

    Returns:
        'user' or 'project' when blocked, otherwise None.
    """
    if not GUARDRAILS_ENABLE_BLOCKING:
        return None

    return await guardrails_store.check_block_scope(user_id)


async def _handle_guardrail_violation_and_maybe_block(
    sse: Any,
    violation_response: GuardrailPolicyViolationContent,
    user_id: str,
) -> Union[GuardrailPolicyViolationContent, GuardrailBlockedContent]:
    """Record a violation, apply block if threshold crossed, and respond.

    Args:
        sse: Active SSE stream.
        violation_response: The default violation warning response.
        user_id: User identifier.

    Returns:
        The GeneratedContent message that was sent to the client. Either of type
        GuardrailPolicyViolationContent or GuardrailBlockedContent.
    """
    if not GUARDRAILS_ENABLE_BLOCKING:
        await sse.send(violation_response.to_sse_event().format())
        return violation_response

    result = await guardrails_store.record_violation(user_id)

    from rasa.builder.copilot.response_handling.base_copilot_response_handler import (
        BaseCopilotResponseHandler,
    )

    message: Union[GuardrailPolicyViolationContent, GuardrailBlockedContent]
    if result.user_blocked_now:
        message = BaseCopilotResponseHandler.respond_to_guardrail_blocked(
            BLOCK_SCOPE_USER
        )
    elif result.project_blocked_now:
        message = BaseCopilotResponseHandler.respond_to_guardrail_blocked(
            BLOCK_SCOPE_PROJECT
        )
    else:
        message = violation_response

    await sse.send(message.to_sse_event().format())
    return message


@bp.route("/git/branch", methods=["POST"])
@openapi.summary("Change Git branch")
@openapi.description(
    "Changes the Git branch for the project folder and retrains the agent. "
    "Returns immediately with a job ID. Connect to `/job-events/<job_id>` "
    "to receive server-sent events (SSE) for real-time progress tracking.\n\n"
    "**SSE Event Flow** (via `/job-events/<job_id>`):\n"
    "1. `received` - Request received by server\n"
    "2. `switching_branch` - Switching to the specified branch\n"
    "3. `branch_switch_success` - Branch switched successfully\n"
    "4. `training` - Training the bot model with the new branch\n"
    "5. `train_success` - Model training completed\n"
    "6. `done` - Branch change completed\n\n"
    "**Error Events:**\n"
    "- `branch_switch_error` - Failed to switch to the branch\n"
    "- `train_error` - Branch switched but training failed\n"
    "- `validation_error` - Branch configuration is invalid\n"
    "- `error` - Unexpected error occurred\n\n"
    "**Usage:**\n"
    "1. Send POST request with Content-Type: application/json\n"
    "2. The response will be a JSON object `{job_id: ...}`\n"
    "3. Connect to `/job-events/<job_id>` for a server-sent event stream of progress."
)
@openapi.tag("git")
@openapi.body(
    {"application/json": model_to_schema(ChangeBranchRequest)},
    description="Branch change request with branch name and creation option.",
    required=True,
    example={"branch_name": "feature/new-flow", "create_if_not_exists": True},
)
@openapi.response(
    200,
    {"application/json": model_to_schema(JobCreateResponse)},
    description="Job created. Poll or subscribe to /job-events/<job_id> for progress.",
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Validation error in request payload",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def handle_change_branch(request: Request) -> HTTPResponse:
    """Handle change branch requests."""
    try:
        payload = ChangeBranchRequest(**request.json)
    except Exception as exc:
        return response.json(
            ApiErrorResponse(
                error="Invalid request", details={"error": str(exc)}
            ).model_dump(),
            status=400,
        )

    try:
        # Allocate job and schedule background task
        job = job_manager.create_job()
        request.app.add_task(
            run_change_branch_job(
                request.app, job, payload.branch_name, payload.create_if_not_exists
            )
        )
        return response.json(JobCreateResponse(job_id=job.id).model_dump(), status=200)
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.change_branch.unexpected_error",
            tags={"endpoint": "/api/git/branch"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to create change branch job",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/git/status", methods=["GET"])
@openapi.summary("Get Git repository status")
@openapi.description(
    "Returns the current Git repository status including current branch, "
    "and uncommitted changes status."
)
@openapi.tag("git")
@openapi.response(
    200,
    {"application/json": model_to_schema(GitStatusResponse)},
    description="Git status retrieved successfully",
    example={
        "current_branch": "main",
        "uncommitted_changes": False,
    },
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def handle_git_status(request: Request) -> HTTPResponse:
    """Handle Git status requests."""
    try:
        project_generator = get_project_generator(request)
        git_service = project_generator.git_service

        git_status = GitStatusResponse(
            current_branch=await git_service.get_current_branch(),
            uncommitted_changes=await git_service.has_uncommitted_changes(),
        )

        return response.json(git_status.model_dump())
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.git_status.unexpected_error",
            tags={"endpoint": "/api/git/status"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to get Git status",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/commits", methods=["GET"])
@openapi.summary("Get commit history")
@openapi.description(
    "Returns the Git commit history for the project repository. "
    "Each commit includes SHA, author, timestamp, and message information."
)
@openapi.tag("git")
@openapi.parameter(
    "limit",
    description="Maximum number of commits to return (default: 50, max: 100)",
    _in="query",
    required=False,
    schema=int,
)
@openapi.response(
    200,
    {"application/json": {"commits": list}},
    description="Commit history retrieved successfully",
    example={
        "commits": [
            {
                "sha": "abc123def456",
                "short_sha": "abc123d",
                "author": "user",
                "email": "user@example.com",
                "timestamp": 1640995200,
                "message": "Update files",
            }
        ]
    },
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def handle_get_commits(request: Request) -> HTTPResponse:
    """Handle get commits requests."""
    try:
        project_generator = get_project_generator(request)
        limit = min(int(request.args.get("limit", 50)), 100)
        commits = await project_generator.git_service.get_commit_history(limit)
        return response.json({"commits": commits})
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.get_commits.unexpected_error",
            tags={"endpoint": "/api/commits"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to get commit history",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/commits/<commit_sha>/rollback", methods=["POST"])
@openapi.summary("Rollback to commit")
@openapi.description(
    "Rollback the project to a specific commit. This creates a background job "
    "that checks out the specified commit and retrains the agent. "
    "Returns immediately with a job ID. Connect to `/job-events/<job_id>` "
    "to receive server-sent events (SSE) for real-time progress tracking.\n\n"
    "**SSE Event Flow** (via `/job-events/<job_id>`):\n"
    "1. `received` - Request received by server\n"
    "2. `rolling_back` - Rolling back to the specified commit\n"
    "3. `rollback_success` - Commit checkout completed\n"
    "4. `training` - Training the bot model with the rolled back files\n"
    "5. `train_success` - Model training completed\n"
    "6. `done` - Rollback completed\n\n"
    "**Error Events:**\n"
    "- `rollback_error` - Failed to rollback to the commit\n"
    "- `train_error` - Rollback succeeded but training failed\n"
    "- `error` - Unexpected error occurred\n\n"
    "**Usage:**\n"
    "1. Send POST request\n"
    "2. The response will be a JSON object `{job_id: ...}`\n"
    "3. Connect to `/job-events/<job_id>` for a server-sent event stream of progress."
)
@openapi.tag("git")
@openapi.response(
    200,
    {"application/json": model_to_schema(JobCreateResponse)},
    description="Job created. Poll or subscribe to /job-events/<job_id> for progress.",
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Invalid commit SHA",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def handle_rollback_to_commit(request: Request, commit_sha: str) -> HTTPResponse:
    """Handle rollback to commit requests."""
    try:
        # Validate commit SHA format
        if not commit_sha or len(commit_sha) < 7:
            return response.json(
                ApiErrorResponse(
                    error="Invalid commit SHA", details={"commit_sha": commit_sha}
                ).model_dump(),
                status=400,
            )

        job = job_manager.create_job()
        request.app.add_task(run_rollback_job(request.app, job, commit_sha))
        return response.json(JobCreateResponse(job_id=job.id).model_dump(), status=200)
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.rollback_to_commit.unexpected_error",
            tags={"endpoint": "/api/commits/<commit_sha>/rollback"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to create rollback job",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@bp.route("/commits/<commit_sha>/diff-with-contents", methods=["GET"])
@openapi.summary("Get commit diff with contents")
@openapi.description(
    "Returns all original and modified file contents for a specific commit. "
    "Binary files are included with `content_original` and `content_modified` "
    "as `null`. Renamed files include `path_original` and `path_modified`."
)
@openapi.tag("git")
@openapi.response(
    200,
    {"application/json": model_to_schema(CommitDiffWithContentsResponse)},
    description="Commit diff with contents retrieved successfully",
)
@openapi.response(
    400,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Invalid commit SHA",
)
@openapi.response(
    404,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Commit not found",
)
@openapi.response(
    500,
    {"application/json": model_to_schema(ApiErrorResponse)},
    description="Internal server error",
)
@openapi.parameter(
    HEADER_USER_ID,
    description=(
        "Optional user id to associate requests (e.g., for telemetry/guardrails)."
    ),
    _in="header",
    required=False,
    schema=str,
)
async def handle_get_commit_diff_with_contents(
    request: Request, commit_sha: str
) -> HTTPResponse:
    """Handle get commit diff with contents requests."""
    try:
        # Validate commit SHA format
        if not commit_sha or len(commit_sha) < 7:
            return response.json(
                ApiErrorResponse(
                    error="Invalid commit SHA", details={"commit_sha": commit_sha}
                ).model_dump(),
                status=400,
            )

        project_generator = get_project_generator(request)
        diff_data = await project_generator.git_service.get_commit_diff_with_contents(
            commit_sha
        )
        return response.json(diff_data.model_dump())
    except CommitNotFoundError as exc:
        return response.json(
            ApiErrorResponse(
                error="Commit not found",
                details={"error": str(exc), "commit_sha": commit_sha},
            ).model_dump(),
            status=HTTPStatus.NOT_FOUND,
        )
    except Exception as exc:
        capture_exception_with_context(
            exc,
            "bot_builder_service.get_commit_diff_with_contents.unexpected_error",
            tags={"endpoint": "/api/commits/<commit_sha>/diff-with-contents"},
        )
        return response.json(
            ApiErrorResponse(
                error="Failed to get commit diff with contents",
                details={"error": str(exc)},
            ).model_dump(),
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )


@observe(capture_input=False, capture_output=False)
async def get_tracker_context_for_copilot(
    request: Request,
    req: CopilotTurnRequest,
    user_id: str,
) -> Optional[TrackerContext]:
    """Check the assistant chat for guardrail policy violations.

    Args:
        request: The request object.
        req: The CopilotTurnRequest object.
        user_id: The user ID.

    Returns:
        The tracker context if the tracker is available.
    """
    tracker = await current_tracker_from_input_channel(request.app, req.session_id)
    tracker_context = TrackerContext.from_tracker(
        tracker, max_turns=COPILOT_ASSISTANT_TRACKER_MAX_TURNS
    )
    if (
        tracker_context is not None
        and llm_service.guardrails_policy_checker is not None
    ):
        tracker_context = await llm_service.guardrails_policy_checker.check_assistant_chat_for_policy_violations(  # noqa: E501
            tracker_context=tracker_context,
            hello_rasa_user_id=user_id,
            hello_rasa_project_id=HELLO_RASA_PROJECT_ID,
            lakera_project_id=LAKERA_ASSISTANT_HISTORY_GUARDRAIL_PROJECT_ID,
        )

    # Track the retrieved tracker context
    CopilotEndpointLangfuseTelemetry.trace_copilot_tracker_context(
        tracker_context=tracker_context,
        max_conversation_turns=COPILOT_ASSISTANT_TRACKER_MAX_TURNS,
        session_id=req.session_id,
    )

    return tracker_context


@observe(capture_input=False, capture_output=False)
def get_relevant_assistant_files_for_copilot(
    project_generator: ProjectGenerator,
) -> BotFiles:
    """Get the relevant assistant files for the copilot.

    Args:
        project_generator: The project generator.

    Returns:
        The relevant assistant files.
    """
    # Copilot doesn't need to know about the docs and any file that is not a core
    # assistant file
    files = project_generator.get_bot_files(
        exclude_docs_directory=True,
        allowed_file_extensions=["yaml", "yml", "py", "jinja", "jinja2"],
    )

    # Track the retrieved assistant files
    CopilotEndpointLangfuseTelemetry.trace_copilot_relevant_assistant_files(
        relevant_assistant_files=files,
    )
    return files
