import asyncio
import os
import shutil
import subprocess
from enum import Enum
from typing import Optional

import aiohttp
import structlog
from pydantic import BaseModel, ConfigDict

from rasa.model_manager import config
from rasa.model_manager.utils import get_multiple_open_ports, logs_path
from rasa.model_manager.warm_rasa_process import start_rasa_process

structlogger = structlog.get_logger()


class BuilderSessionStatus(str, Enum):
    """Enum for the builder session status."""

    CLONING = "cloning"
    QUEUED = "queued"
    RUNNING = "running"
    STOPPED = "stopped"
    FAILED = "failed"


class BuilderSession(BaseModel):
    """Store information about a running builder session."""

    model_config = ConfigDict(arbitrary_types_allowed=True)

    deployment_id: str
    status: BuilderSessionStatus
    process: Optional[subprocess.Popen] = None
    url: str
    internal_url: str
    port: int
    log_id: str
    returncode: Optional[int] = None
    repo_url: str
    branch: str
    error_message: Optional[str] = None

    # GitHub config - stored in memory for token refresh
    # These are used by the model service to generate new tokens when requested
    github_installation_id: Optional[str] = None
    github_repo_owner: Optional[str] = None
    github_repo_name: Optional[str] = None

    def is_alive(self) -> bool:
        """Check if the builder session is alive."""
        if self.process is None:
            return False
        return self.process.poll() is None

    def is_status_indicating_alive(self) -> bool:
        """Check if the status indicates that the session is alive."""
        return self.status in {
            BuilderSessionStatus.CLONING,
            BuilderSessionStatus.QUEUED,
            BuilderSessionStatus.RUNNING,
        }

    def has_died_recently(self) -> bool:
        """Check if the session has died recently."""
        return self.is_status_indicating_alive() and not self.is_alive()

    async def completed_startup_recently(self) -> bool:
        """Check if the session has completed startup recently."""
        return (
            self.status == BuilderSessionStatus.QUEUED
            and await is_builder_startup_finished(self)
        )


def builder_path(deployment_id: str) -> str:
    """Return the path to the builder directory for a given deployment id."""
    return os.path.abspath(
        f"{config.SERVER_BASE_WORKING_DIRECTORY}/builder/{deployment_id}"
    )


async def is_builder_startup_finished(session: BuilderSession) -> bool:
    """Send a request to see if the builder session is up and running.

    The rasa hello command runs the builder service which exposes
    its health endpoint at /api/ (not /license like rasa run).
    """
    health_timeout = aiohttp.ClientTimeout(total=5, sock_connect=2, sock_read=3)
    try:
        async with aiohttp.ClientSession(timeout=health_timeout) as client:
            async with client.get(f"{session.internal_url}/api/") as resp:
                return resp.status == 200
    except aiohttp.client_exceptions.ClientConnectorError:
        return False


def set_builder_status_to_stopped(session: BuilderSession) -> None:
    """Set a builder session state to stopped."""
    structlogger.info(
        "builder_service.session_stopped",
        deployment_id=session.deployment_id,
        status=session.process.returncode if session.process else None,
    )
    session.status = BuilderSessionStatus.STOPPED
    session.returncode = session.process.returncode if session.process else None


def set_builder_status_to_running(session: BuilderSession) -> None:
    """Set a builder session state to running."""
    structlogger.info(
        "builder_service.session_running",
        deployment_id=session.deployment_id,
    )
    session.status = BuilderSessionStatus.RUNNING


def set_builder_status_to_failed(session: BuilderSession, error: str) -> None:
    """Set a builder session state to failed."""
    structlogger.error(
        "builder_service.session_failed",
        deployment_id=session.deployment_id,
        error=error,
    )
    session.status = BuilderSessionStatus.FAILED
    session.error_message = error


def clone_repository(repo_url: str, branch: str, target_path: str) -> None:
    """Clone a git repository and switch to a specific branch.

    Args:
        repo_url: The URL of the git repository.
        branch: The branch to checkout.
        target_path: The path where the repository should be cloned.

    Raises:
        subprocess.CalledProcessError: If the git command fails.
    """
    structlogger.info(
        "builder_service.cloning_repository",
        repo_url=repo_url,
        branch=branch,
        target_path=target_path,
    )

    # Clone with specific branch
    result = subprocess.run(
        ["git", "clone", "--branch", branch, "--single-branch", repo_url, target_path],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        raise subprocess.CalledProcessError(
            result.returncode, result.args, result.stdout, result.stderr
        )

    structlogger.info(
        "builder_service.clone_successful",
        repo_url=repo_url,
        branch=branch,
    )


def prepare_builder_directory(
    builder_base_path: str,
    repo_url: str,
    branch: str,
) -> None:
    """Prepare the builder directory by cloning the repository.

    Args:
        builder_base_path: The base path for the builder session.
        repo_url: The URL of the git repository.
        branch: The branch to checkout.
    """
    if os.path.exists(builder_base_path):
        shutil.rmtree(builder_base_path, ignore_errors=True)

    os.makedirs(os.path.dirname(builder_base_path), exist_ok=True)

    clone_repository(repo_url, branch, builder_base_path)


def start_builder_process(
    deployment_id: str,
    builder_base_path: str,
    base_url_path: str,
) -> tuple[subprocess.Popen, str, int, str]:
    """Start the rasa hello process.

    Args:
        deployment_id: The deployment ID.
        builder_base_path: The path to the cloned repository.
        base_url_path: The base URL path for the server.

    Returns:
        Tuple of (process, log_id, port, internal_url).
    """
    port, mcp_port = get_multiple_open_ports(2)

    arguments = [
        "hello",
        f"--port={port}",
        f"--mcp-port={mcp_port}",
        "--no-browser",
    ]

    structlogger.debug(
        "builder_service.starting_command",
        deployment_id=deployment_id,
        arguments=" ".join(arguments),
    )

    warm_process = start_rasa_process(cwd=builder_base_path, arguments=arguments)

    internal_url = f"http://localhost:{port}"

    structlogger.info(
        "builder_service.starting",
        deployment_id=deployment_id,
        log=logs_path(warm_process.log_id),
        url=internal_url,
        port=port,
        mcp_port=mcp_port,
        pid=warm_process.process.pid,
    )

    return warm_process.process, warm_process.log_id, port, internal_url


def run_builder(
    deployment_id: str,
    repo_url: str,
    branch: str,
    base_url_path: str,
) -> BuilderSession:
    """Start a builder session by cloning a repo and running rasa hello.

    Args:
        deployment_id: The deployment ID.
        repo_url: The URL of the git repository.
        branch: The branch to checkout.
        base_url_path: The base URL path for the server.

    Returns:
        The created BuilderSession.
    """
    with structlog.contextvars.bound_contextvars(
        repo_url=repo_url, branch=branch, deployment_id=deployment_id
    ):
        builder_base_path = builder_path(deployment_id)

        # Create initial session in CLONING state
        # Port and internal_url are placeholders, updated after process starts
        session = BuilderSession(
            deployment_id=deployment_id,
            status=BuilderSessionStatus.CLONING,
            process=None,
            url=f"{base_url_path}?deployment_id={deployment_id}",
            internal_url="",
            port=0,
            log_id="",
            repo_url=repo_url,
            branch=branch,
        )

        try:
            # Clone the repository
            prepare_builder_directory(builder_base_path, repo_url, branch)

            # Start the rasa hello process
            process, log_id, port, internal_url = start_builder_process(
                deployment_id, builder_base_path, base_url_path
            )

            session.process = process
            session.log_id = log_id
            session.port = port
            session.internal_url = internal_url
            session.status = BuilderSessionStatus.QUEUED

        except subprocess.CalledProcessError as e:
            error_msg = f"Git clone failed: {e.stderr or e.stdout or str(e)}"
            set_builder_status_to_failed(session, error_msg)
        except Exception as e:
            set_builder_status_to_failed(session, str(e))

        return session


def prepare_scaffold_directory(builder_base_path: str) -> None:
    """Prepare the builder directory with a minimal scaffold project.

    This creates an empty project structure that the builder can use
    before receiving actual content (e.g., from a GitHub repository).

    Args:
        builder_base_path: The base path for the builder session.
    """
    if os.path.exists(builder_base_path):
        shutil.rmtree(builder_base_path, ignore_errors=True)

    os.makedirs(builder_base_path, exist_ok=True)

    # Create minimal project structure for rasa hello to start
    data_dir = os.path.join(builder_base_path, "data")
    os.makedirs(data_dir, exist_ok=True)

    # Create minimal config.yml
    config_content = """recipe: default.v1
language: en
"""
    with open(os.path.join(builder_base_path, "config.yml"), "w") as f:
        f.write(config_content)

    structlogger.info(
        "builder_service.scaffold_prepared",
        builder_base_path=builder_base_path,
    )


def run_builder_scaffold(
    deployment_id: str,
    base_url_path: str,
    github_installation_id: Optional[str] = None,
    github_repo_owner: Optional[str] = None,
    github_repo_name: Optional[str] = None,
) -> BuilderSession:
    """Start a builder session with a scaffold project (no repository clone).

    This is used when the builder will receive content via an API call
    (e.g., from /api/github-to-bot) rather than cloning directly.

    Args:
        deployment_id: The deployment ID.
        base_url_path: The base URL path for the server.
        github_installation_id: GitHub App installation ID for token refresh.
        github_repo_owner: Repository owner (for tracking).
        github_repo_name: Repository name (for tracking).

    Returns:
        The created BuilderSession.
    """
    with structlog.contextvars.bound_contextvars(deployment_id=deployment_id):
        builder_base_path = builder_path(deployment_id)

        repo_url = ""
        if github_repo_owner and github_repo_name:
            repo_url = f"https://github.com/{github_repo_owner}/{github_repo_name}.git"

        # Create initial session in QUEUED state (ready to receive content)
        session = BuilderSession(
            deployment_id=deployment_id,
            status=BuilderSessionStatus.QUEUED,
            process=None,
            url=f"{base_url_path}?deployment_id={deployment_id}",
            internal_url="",
            port=0,
            log_id="",
            repo_url=repo_url,
            branch="",
            github_installation_id=github_installation_id,
            github_repo_owner=github_repo_owner,
            github_repo_name=github_repo_name,
        )

        try:
            # Prepare scaffold directory
            prepare_scaffold_directory(builder_base_path)

            # Start the rasa hello process
            process, log_id, port, internal_url = start_builder_process(
                deployment_id, builder_base_path, base_url_path
            )

            session.process = process
            session.log_id = log_id
            session.port = port
            session.internal_url = internal_url
            # TODO: this needs to be changed to use an external URL, either of
            #   the instance or the model service
            session.url = internal_url

            structlogger.info(
                "builder_service.scaffold_session_started",
                deployment_id=deployment_id,
                internal_url=internal_url,
                has_github_config=bool(github_installation_id),
            )

        except Exception as e:
            set_builder_status_to_failed(session, str(e))

        return session


async def update_builder_status(session: BuilderSession) -> None:
    """Update the status of a builder session based on the process state."""
    try:
        if session.status == BuilderSessionStatus.FAILED:
            # Don't update failed sessions
            return

        if session.has_died_recently():
            set_builder_status_to_stopped(session)
        elif await session.completed_startup_recently():
            set_builder_status_to_running(session)
    except Exception as e:
        structlogger.error("builder_service.update_status.error", error=str(e))


def terminate_builder(session: BuilderSession) -> None:
    """Terminate the builder session process."""
    if not session.is_status_indicating_alive():
        return

    try:
        if session.process:
            session.process.terminate()
            structlogger.info(
                "builder_service.stop.stopped",
                deployment_id=session.deployment_id,
                status=session.process.returncode,
            )
        session.status = BuilderSessionStatus.STOPPED
        session.returncode = session.process.returncode if session.process else None
    except ProcessLookupError:
        structlogger.debug(
            "builder_service.stop.process_not_found",
            deployment_id=session.deployment_id,
        )


# =============================================================================
# Builder Initialization Tasks
# =============================================================================


async def wait_for_builder_ready(
    session: BuilderSession,
    max_wait: int = 60,
    poll_interval: float = 0.5,
) -> bool:
    """Wait for builder to be ready.

    Args:
        session: The builder session.
        max_wait: Maximum seconds to wait.
        poll_interval: Seconds between health checks.

    Returns:
        True if builder is ready, False if timed out or died.
    """
    deployment_id = session.deployment_id

    structlogger.info(
        "builder_service.init.waiting",
        deployment_id=deployment_id,
    )

    waited = 0.0
    while waited < max_wait:
        if await is_builder_startup_finished(session):
            return True
        if not session.is_alive():
            structlogger.error(
                "builder_service.init.builder_died",
                deployment_id=deployment_id,
            )
            return False
        await asyncio.sleep(poll_interval)
        waited += poll_interval

    structlogger.error(
        "builder_service.init.timeout",
        deployment_id=deployment_id,
    )
    set_builder_status_to_failed(session, "Builder startup timeout")
    return False


async def call_builder_endpoint(
    session: BuilderSession,
    endpoint: str,
    payload: dict,
    action_name: str,
) -> bool:
    """Call an endpoint on the builder.

    Args:
        session: The builder session.
        endpoint: API endpoint path (e.g., "/api/github-to-bot").
        payload: JSON payload to send.
        action_name: Name for logging (e.g., "github_init").

    Returns:
        True if successful, False otherwise.
    """
    deployment_id = session.deployment_id
    url = f"{session.internal_url}{endpoint}"

    structlogger.info(
        f"builder_service.{action_name}.calling",
        deployment_id=deployment_id,
        endpoint=endpoint,
    )

    try:
        async with aiohttp.ClientSession() as client:
            async with client.post(url, json=payload, timeout=30) as resp:
                if resp.status != 200:
                    error_body = await resp.text()
                    structlogger.error(
                        f"builder_service.{action_name}.failed",
                        deployment_id=deployment_id,
                        status=resp.status,
                        error=error_body,
                    )
                    set_builder_status_to_failed(
                        session, f"Builder init failed: {error_body}"
                    )
                    return False

                result = await resp.json()
                structlogger.info(
                    f"builder_service.{action_name}.success",
                    deployment_id=deployment_id,
                    job_id=result.get("job_id"),
                )
                return True

    except aiohttp.ClientError as e:
        structlogger.error(
            f"builder_service.{action_name}.network_error",
            deployment_id=deployment_id,
            error=str(e),
        )
        set_builder_status_to_failed(session, f"Network error: {e}")
        return False


async def initialize_builder_from_github(
    session: BuilderSession,
    branch: str,
    template_name: Optional[str] = None,
) -> None:
    """Wait for builder to be ready, then initialize from GitHub.

    If template_name is provided, this will:
    1. Clone the repository
    2. Check if the branch has any existing commits
    3. If no commits: initialize from template, commit, and push
    4. If has commits: fail with an error

    Args:
        session: The builder session (must have github_installation_id set).
        branch: Git branch to checkout.
        template_name: Optional template to initialize new empty repos with.
    """
    # Import here to avoid circular dependency
    from rasa.model_manager.github_token import (
        GitHubTokenError,
        get_github_token_service,
    )

    deployment_id = session.deployment_id

    if not await wait_for_builder_ready(session):
        return

    # Generate GitHub token
    token_service = get_github_token_service()
    if not token_service.is_configured():
        structlogger.error(
            "builder_service.github_init.token_service_not_configured",
            deployment_id=deployment_id,
        )
        set_builder_status_to_failed(session, "GitHub App not configured")
        return

    try:
        token, expires_at = await token_service.get_installation_token(
            session.github_installation_id
        )
    except GitHubTokenError as e:
        structlogger.error(
            "builder_service.github_init.token_generation_failed",
            deployment_id=deployment_id,
            error=str(e),
        )
        set_builder_status_to_failed(session, f"Failed to get GitHub token: {e}")
        return

    # Build the repo URL and token refresh URL
    owner = session.github_repo_owner
    repo_name = session.github_repo_name
    repo_url = f"https://github.com/{owner}/{repo_name}.git"
    port = config.SERVER_PORT
    token_refresh_url = f"http://127.0.0.1:{port}/builder/{deployment_id}/github-token"

    payload = {
        "repo_url": repo_url,
        "github_token": token,
        "token_refresh_url": token_refresh_url,
        "branch": branch,
        "owner": owner,
        "repo": repo_name,
    }

    # Choose endpoint based on whether template is provided
    if template_name:
        # Clone repo then initialize from template (for new projects)
        payload["template_name"] = template_name
        endpoint = "/api/github-clone-then-template"
        action_name = "github_clone_then_template"
    else:
        # Just clone and train (for existing projects)
        endpoint = "/api/github-to-bot"
        action_name = "github_init"

    if await call_builder_endpoint(session, endpoint, payload, action_name):
        session.repo_url = repo_url
        session.branch = branch


async def initialize_builder_from_template(
    session: BuilderSession,
    template_name: str,
) -> None:
    """Wait for builder to be ready, then initialize from template.

    Args:
        session: The builder session.
        template_name: Name of the template to use.
    """
    if not await wait_for_builder_ready(session):
        return

    payload = {"template_name": template_name}

    await call_builder_endpoint(
        session, "/api/template-to-bot", payload, "template_init"
    )
