import json
import logging
from typing import Annotated, Any

import typer
from httpx import HTTPError
from pydantic import BaseModel
from rich.markup import escape
from rich.table import Table
from rich.text import Text
from rich_toolkit import RichToolkit

from fastapi_cloud_cli.api import (
    BUILD_FAILED_STATUSES,
    TERMINAL_STATUSES,
    APIClient,
    BuildFailure,
    BuildLogLineGeneric,
    BuildLogLineMessage,
    Deployment,
    StreamLogError,
    TooManyRetriesError,
    get_http_error_code,
    get_http_error_hint,
    handle_http_error,
)
from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.cli import (
    FastAPIRichToolkit,
    get_details_table,
)
from fastapi_cloud_cli.utils.dates import format_last_updated
from fastapi_cloud_cli.utils.errors import ErrorCode
from fastapi_cloud_cli.utils.execution import JsonOutputOption

logger = logging.getLogger(__name__)

DEFAULT_LIMIT = 100
DEFAULT_OFFSET = 0


class DeploymentsListAPIResponse(BaseModel):
    data: list[Deployment]
    count: int


class DeploymentsListOutput(BaseModel):
    deployments: list[Deployment]
    total_count: int
    limit: int
    offset: int


class DeploymentGetOutput(BaseModel):
    deployment: Deployment


class BuildLogOutput(BaseModel):
    id: str | None = None
    message: str


class BuildLogsOutput(BaseModel):
    deployment_id: str
    failed: bool
    logs: list[BuildLogOutput]
    failure: BuildFailure | None = None


def _get_deployments(
    client: APIClient, *, app_id: str, limit: int, offset: int
) -> DeploymentsListOutput:
    response = client.get(
        f"/apps/{app_id}/deployments/",
        params={
            "limit": limit,
            "skip": offset,
        },
    )
    response.raise_for_status()

    data = DeploymentsListAPIResponse.model_validate(response.json())

    return DeploymentsListOutput(
        deployments=data.data,
        total_count=data.count,
        limit=limit,
        offset=offset,
    )


def _render_deployments_list_output(
    data: DeploymentsListOutput, toolkit: RichToolkit
) -> None:
    toolkit.print_title("deployments")
    toolkit.print_line()

    if not data.deployments:
        toolkit.print("No deployments found.", bullet=False)
        return

    table = Table.grid(padding=(0, 2), pad_edge=False)
    table.add_column("ID", no_wrap=True)
    table.add_column("Status", no_wrap=True)
    table.add_column("Created", style="dim", no_wrap=True)
    table.add_row("[bold]ID[/bold]", "[bold]Status[/bold]", "[bold]Created[/bold]")
    table.add_row("", "", "")

    for deployment in data.deployments:
        table.add_row(
            deployment.id,
            deployment.status.value,
            Text(format_last_updated(deployment.created_at)),
        )

    toolkit.print(table, bullet=False)


def _render_deployment_get_output(
    data: DeploymentGetOutput, toolkit: FastAPIRichToolkit
) -> None:
    deployment = data.deployment

    toolkit.print_title("deployment")
    toolkit.print_line()

    toolkit.print(f"[bold]{deployment.id}[/bold]", emoji="🚀")
    toolkit.print_line()
    toolkit.print(
        get_details_table(
            [
                ("app id", deployment.app_id),
                ("slug", deployment.slug),
                ("status", deployment.status.value),
                ("created", format_last_updated(deployment.created_at)),
                (
                    "url",
                    deployment.url
                    if deployment.url is not None
                    else Text("-", style="dim"),
                ),
                (
                    "dashboard",
                    Text(
                        deployment.dashboard_url,
                        style=f"link {deployment.dashboard_url}",
                    )
                    if deployment.dashboard_url is not None
                    else Text("-", style="dim"),
                ),
            ]
        )
    )

    if deployment.failure is not None:
        toolkit.print_line()
        _print_deployment_failure(toolkit, deployment.failure)


def _print_deployment_failure(
    toolkit: FastAPIRichToolkit, failure: BuildFailure
) -> None:
    toolkit.print("This deployment failed with the following error:", emoji="🚨")
    toolkit.print_line()
    toolkit.print(Text(failure.error_title, style="bold"))
    toolkit.print_line()
    toolkit.print(Text(failure.error_message))
    if failure.error_hint:
        toolkit.print_line()
        toolkit.print_hint(escape(failure.error_hint))


def _print_build_log_json(
    deployment_id: str,
    record_type: str,
    *,
    log_id: str | None,
    message: str | None = None,
    failure: BuildFailure | None = None,
) -> None:
    record = {
        "type": record_type,
        "deployment_id": deployment_id,
        "id": log_id,
        "message": message,
        "failure": failure.model_dump(mode="json") if failure is not None else None,
    }

    typer.echo(
        json.dumps(
            {key: value for key, value in record.items() if value is not None},
            separators=(",", ":"),
        )
    )


BUILD_LOG_BULLET = "[dim]▕[/dim]"


def _print_build_log_line(toolkit: RichToolkit, message: str) -> None:
    text = Text.from_ansi(message.rstrip())

    if not text.plain.strip():
        # Keep the log marker when the style would otherwise omit an empty line.
        text = Text("\u200b")

    toolkit.print(text, emoji=BUILD_LOG_BULLET)


def _render_build_logs_output(
    data: BuildLogsOutput, toolkit: FastAPIRichToolkit
) -> None:
    for log in data.logs:
        _print_build_log_line(toolkit, log.message)

    if data.failure:
        if data.logs:
            toolkit.print_line()

        _print_deployment_failure(toolkit, data.failure)
    elif data.failed:
        toolkit.print_line()
        toolkit.print_error("Build failed.")
    elif not data.logs:
        toolkit.print("No build logs found.")


def _stream_build_logs(
    toolkit: FastAPIRichToolkit,
    client: APIClient,
    deployment_id: str,
) -> bool:
    deployment = client.get_deployment(deployment_id)
    terminal_log: BuildLogLineGeneric | None = None

    for log in client.stream_build_logs(
        deployment_id, follow=deployment.status not in TERMINAL_STATUSES
    ):
        if isinstance(log, BuildLogLineMessage):
            if toolkit.mode == "json":
                _print_build_log_json(
                    deployment_id,
                    "log",
                    log_id=log.id,
                    message=log.message,
                )
            else:
                _print_build_log_line(toolkit, log.message)
        else:
            terminal_log = log

    if deployment.status not in TERMINAL_STATUSES:
        deployment = client.get_deployment(deployment_id)

    failed = (
        terminal_log is not None and terminal_log.type == "failed"
    ) or deployment.status in BUILD_FAILED_STATUSES

    if toolkit.mode == "json":
        if failed or terminal_log is not None or deployment.status in TERMINAL_STATUSES:
            _print_build_log_json(
                deployment_id,
                "failed" if failed else "complete",
                log_id=terminal_log.id if terminal_log is not None else None,
                failure=deployment.failure,
            )
    elif deployment.failure is not None:
        toolkit.print_line()
        _print_deployment_failure(toolkit, deployment.failure)
    elif failed:
        toolkit.print_line()
        toolkit.print_error("Build failed.")

    return failed


def _fetch_build_logs(client: APIClient, deployment_id: str) -> BuildLogsOutput:
    logs: list[BuildLogOutput] = []
    failed = False

    for log in client.stream_build_logs(deployment_id, follow=False):
        if isinstance(log, BuildLogLineMessage):
            logs.append(BuildLogOutput(id=log.id, message=log.message))

        elif log.type == "failed":
            failed = True

    deployment = client.get_deployment(deployment_id)
    return BuildLogsOutput(
        deployment_id=deployment_id,
        failed=failed or deployment.status in BUILD_FAILED_STATUSES,
        logs=logs,
        failure=deployment.failure,
    )


def _handle_build_log_error(
    toolkit: FastAPIRichToolkit,
    error: StreamLogError,
) -> None:
    hint: str | None = None

    if error.status_code == 404:
        code: ErrorCode = "not_found"
        message = "Deployment not found."

    elif isinstance(error.__cause__, HTTPError):
        code = get_http_error_code(error.__cause__)
        message = handle_http_error(error.__cause__)
        hint = get_http_error_hint(code)

    else:
        code = "api_error"
        message = f"Error streaming build logs: {error}"

    toolkit.fail(
        code,
        message,
        hint=hint,
        render_output=_render_build_log_error,
    )


def _render_build_log_error(
    toolkit: FastAPIRichToolkit,
    *,
    code: ErrorCode,
    message: str,
    hint: str,
) -> None:
    toolkit.print_error(message)
    if hint:
        toolkit.print_line()
        toolkit.print_hint(hint)


deployments_app = typer.Typer(
    no_args_is_help=True,
    help="Manage the deployments of your app.",
)


@deployments_app.command("get", cls=UserCommand)
def get_deployment(
    ctx: typer.Context,
    deployment_id: Annotated[
        str,
        typer.Argument(
            help="ID of the deployment to return.",
        ),
    ],
    app_id: Annotated[
        str | None,
        typer.Option(
            "--app-id",
            help="ID of the app that owns the deployment.",
        ),
    ] = None,
    json_output: JsonOutputOption = False,
) -> Any:
    """
    Get a FastAPI Cloud deployment by ID.
    """

    toolkit = get_user_command_context(ctx).toolkit

    resolve_app_id_or_fail(toolkit, app_id=app_id)

    with APIClient() as client:
        with (
            toolkit.progress(
                title="Fetching deployment",
                transient=True,
            ) as progress,
            client.handle_http_errors(
                progress,
                default_message="Error fetching deployment. Please try again later.",
                not_found_message="Deployment not found.",
                toolkit=toolkit,
            ),
        ):
            deployment = client.get_deployment(deployment_id)

    toolkit.success(
        DeploymentGetOutput(deployment=deployment),
        render_output=_render_deployment_get_output,
    )


@deployments_app.command("build-logs", cls=UserCommand)
def build_logs(
    ctx: typer.Context,
    deployment_id: Annotated[
        str,
        typer.Argument(
            help="ID of the deployment whose build logs should be returned.",
        ),
    ],
    follow: Annotated[
        bool,
        typer.Option(
            "--follow/--no-follow",
            "-f",
            help="Stream build logs until the build reaches a terminal state.",
        ),
    ] = True,
    json_output: JsonOutputOption = False,
) -> None:
    """
    Stream or fetch build logs for a FastAPI Cloud deployment.
    """

    toolkit = get_user_command_context(ctx).toolkit

    if follow:
        toolkit.print(
            f"Streaming build logs for [bold]{deployment_id}[/bold]...",
            emoji="📡",
        )
    else:
        toolkit.print(
            f"Fetching build logs for [bold]{deployment_id}[/bold]...",
            emoji="📜",
        )
    toolkit.print_line()

    try:
        with APIClient() as client:
            if follow:
                failed = _stream_build_logs(toolkit, client, deployment_id)
            else:
                result = _fetch_build_logs(client, deployment_id)
                toolkit.success(result, render_output=_render_build_logs_output)
                failed = result.failed

    except KeyboardInterrupt:  # pragma: no cover
        toolkit.print_line()
        return
    except StreamLogError as e:
        _handle_build_log_error(toolkit, e)
    except HTTPError as e:
        code = get_http_error_code(e)
        toolkit.fail(
            code,
            handle_http_error(e, not_found_message="Deployment not found."),
            hint=get_http_error_hint(code),
            render_output=_render_build_log_error,
        )

    except (TooManyRetriesError, TimeoutError):
        message = "Lost connection to build log stream. Please try again later."
        toolkit.fail(
            "network_error",
            message,
            hint="Please try again later.",
            render_output=_render_build_log_error,
        )

    if failed:
        raise typer.Exit(1)


@deployments_app.command("list", cls=UserCommand)
def list_deployments(
    ctx: typer.Context,
    app_id: Annotated[
        str | None,
        typer.Option(
            "--app-id",
            help="ID of the app whose deployments should be listed.",
        ),
    ] = None,
    limit: Annotated[
        int,
        typer.Option(
            "--limit",
            help="Maximum number of deployments to return.",
            min=1,
        ),
    ] = DEFAULT_LIMIT,
    offset: Annotated[
        int,
        typer.Option(
            "--offset",
            help="Offset into the deployment result set.",
            min=0,
        ),
    ] = DEFAULT_OFFSET,
    json_output: JsonOutputOption = False,
) -> Any:
    """
    List FastAPI Cloud deployments for an app.
    """

    toolkit = get_user_command_context(ctx).toolkit

    target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)

    with APIClient() as client:
        with toolkit.progress(
            title="Fetching deployments",
            transient=True,
        ) as progress:
            with client.handle_http_errors(
                progress,
                default_message="Error fetching deployments. Please try again later.",
                not_found_message="App not found.",
                toolkit=toolkit,
            ):
                result = _get_deployments(
                    client,
                    app_id=target_app_id,
                    limit=limit,
                    offset=offset,
                )

    toolkit.success(result, render_output=_render_deployments_list_output)
