import asyncio
import os
from types import TracebackType
from typing import Callable, Optional, List, Union

import httpx

from e2b.api import handle_api_exception
from e2b.io_utils import aiter_io_chunks
from e2b.api.client.api.templates import (
    post_v3_templates,
    get_templates_template_id_files_hash,
    post_v_2_templates_template_id_builds_build_id,
    get_templates_template_id_builds_build_id_status,
    get_templates_aliases_alias,
)
from e2b.api.client.api.tags import (
    post_templates_tags,
    delete_templates_tags,
    get_templates_template_id_tags,
)
from e2b.api.client.client import AuthenticatedClient
from e2b.api.client.models import (
    TemplateBuildRequestV3,
    TemplateBuildStartV2,
    TemplateBuildFileUpload,
    Error,
    AssignTemplateTagsRequest,
    DeleteTemplateTagsRequest,
)
from e2b.api.client.types import UNSET, Unset
from e2b.exceptions import BuildException, FileUploadException, TemplateException
from e2b.template.logger import LogEntry
from e2b.template.types import (
    TemplateType,
    BuildStatusReason,
    TemplateBuildStatus,
    TemplateBuildStatusResponse,
    TemplateTag,
    TemplateTagInfo,
)
from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS
from e2b.template.utils import get_build_step_index, tar_file_stream


async def request_build(
    client: AuthenticatedClient,
    name: str,
    tags: Optional[List[str]],
    cpu_count: int,
    memory_mb: int,
):
    res = await post_v3_templates.asyncio_detailed(
        client=client,
        body=TemplateBuildRequestV3(
            name=name,
            tags=tags if tags else UNSET,
            cpu_count=cpu_count,
            memory_mb=memory_mb,
        ),
    )

    if res.status_code >= 300:
        raise handle_api_exception(res, BuildException)

    if isinstance(res.parsed, Error):
        raise BuildException(f"API error: {res.parsed.message}")

    if res.parsed is None:
        raise BuildException("Failed to request build")

    return res.parsed


async def get_file_upload_link(
    client: AuthenticatedClient,
    template_id: str,
    files_hash: str,
    stack_trace: Optional[TracebackType] = None,
) -> TemplateBuildFileUpload:
    res = await get_templates_template_id_files_hash.asyncio_detailed(
        template_id=template_id,
        hash_=files_hash,
        client=client,
    )

    if res.status_code >= 300:
        raise handle_api_exception(res, FileUploadException, stack_trace)

    if isinstance(res.parsed, Error):
        raise FileUploadException(f"API error: {res.parsed.message}").with_traceback(
            stack_trace
        )

    if res.parsed is None:
        raise FileUploadException("Failed to get file upload link").with_traceback(
            stack_trace
        )

    return res.parsed


async def upload_file(
    api_client: AuthenticatedClient,
    file_name: str,
    context_path: str,
    url: str,
    ignore_patterns: List[str],
    resolve_symlinks: bool,
    stack_trace: Optional[TracebackType],
    request_timeout: Optional[float] = None,
):
    # Uploading a large build-context archive can take far longer than the 60s
    # general API timeout, so default to a 1-hour upload timeout unless the
    # caller set an explicit request_timeout. Matches the JS SDK
    # (FILE_UPLOAD_TIMEOUT_MS).
    upload_timeout = (
        request_timeout if request_timeout is not None else FILE_UPLOAD_TIMEOUT_SECONDS
    )
    try:
        tar_file = tar_file_stream(
            file_name, context_path, ignore_patterns, resolve_symlinks
        )
        try:
            size = os.fstat(tar_file.fileno()).st_size

            async with httpx.AsyncClient(
                timeout=httpx.Timeout(upload_timeout),
                verify=api_client._verify_ssl,
                follow_redirects=api_client._follow_redirects,
                proxy=getattr(api_client, "_proxy", None),
                http2=False,
            ) as client:
                # Stream the archive from disk via an async iterator. The
                # explicit Content-Length suppresses chunked transfer
                # encoding, which S3 presigned URLs reject.
                response = await client.put(
                    url,
                    content=aiter_io_chunks(tar_file),
                    headers={"Content-Length": str(size)},
                )
            response.raise_for_status()
        finally:
            # Closing the spooled temp file is best-effort: a failure here
            # must not mask a successful upload as a FileUploadException,
            # nor overwrite a real upload error.
            try:
                tar_file.close()
            except Exception:
                pass
    except httpx.HTTPStatusError as e:
        raise FileUploadException(f"Failed to upload file: {e}").with_traceback(
            stack_trace
        )
    except Exception as e:
        raise FileUploadException(f"Failed to upload file: {e}").with_traceback(
            stack_trace
        )


async def trigger_build(
    client: AuthenticatedClient,
    template_id: str,
    build_id: str,
    template: TemplateType,
) -> None:
    # Convert template dict to TemplateBuildStartV2 model using from_dict
    template_data = TemplateBuildStartV2.from_dict(template)

    res = await post_v_2_templates_template_id_builds_build_id.asyncio_detailed(
        template_id=template_id,
        build_id=build_id,
        client=client,
        body=template_data,
    )

    if res.status_code >= 300:
        raise handle_api_exception(res, BuildException)


def _map_log_entry(entry) -> LogEntry:
    """Map API log entry to LogEntry type."""
    return LogEntry(
        timestamp=entry.timestamp,
        level=entry.level.value,
        message=entry.message,
    )


def _map_build_status_reason(reason) -> Optional[BuildStatusReason]:
    """Map API build status reason to custom BuildStatusReason type."""
    if reason is None or isinstance(reason, Unset):
        return None
    return BuildStatusReason(
        message=reason.message,
        step=reason.step if not isinstance(reason.step, Unset) else None,
        log_entries=[
            _map_log_entry(e)
            for e in (
                reason.log_entries
                if not isinstance(reason.log_entries, Unset) and reason.log_entries
                else []
            )
        ],
    )


async def get_build_status(
    client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int
) -> TemplateBuildStatusResponse:
    res = await get_templates_template_id_builds_build_id_status.asyncio_detailed(
        template_id=template_id,
        build_id=build_id,
        client=client,
        logs_offset=logs_offset,
    )

    if res.status_code >= 300:
        raise handle_api_exception(res, BuildException)

    if isinstance(res.parsed, Error):
        raise BuildException(f"API error: {res.parsed.message}")

    if res.parsed is None:
        raise BuildException("Failed to get build status")

    return TemplateBuildStatusResponse(
        build_id=res.parsed.build_id,
        template_id=res.parsed.template_id,
        status=TemplateBuildStatus(res.parsed.status.value),
        log_entries=[_map_log_entry(e) for e in res.parsed.log_entries],
        logs=res.parsed.logs,
        reason=_map_build_status_reason(res.parsed.reason),
    )


async def wait_for_build_finish(
    client: AuthenticatedClient,
    template_id: str,
    build_id: str,
    on_build_logs: Optional[Callable[[LogEntry], None]] = None,
    logs_refresh_frequency: float = 0.2,
    stack_traces: List[Union[TracebackType, None]] = [],
):
    logs_offset = 0
    status = TemplateBuildStatus.BUILDING

    async def poll_status() -> TemplateBuildStatusResponse:
        nonlocal logs_offset
        build_status = await get_build_status(
            client, template_id, build_id, logs_offset
        )

        logs_offset += len(build_status.log_entries)

        for log_entry in build_status.log_entries:
            if on_build_logs:
                on_build_logs(log_entry)

        return build_status

    while status in [TemplateBuildStatus.BUILDING, TemplateBuildStatus.WAITING]:
        build_status = await poll_status()

        status = build_status.status

        if status in [TemplateBuildStatus.READY, TemplateBuildStatus.ERROR]:
            # The status endpoint returns at most 100 log entries per call, so
            # the terminal response may not include the last logs - keep
            # fetching until they are drained.
            tail_status = build_status
            while len(tail_status.log_entries) > 0:
                tail_status = await poll_status()

            if status == TemplateBuildStatus.READY:
                return

            traceback = None
            if build_status.reason and build_status.reason.step:
                # Find the corresponding stack trace for the failed step
                step_index = get_build_step_index(
                    build_status.reason.step, len(stack_traces)
                )
                if step_index < len(stack_traces):
                    traceback = stack_traces[step_index]

            raise BuildException(
                build_status.reason.message if build_status.reason else "Build failed"
            ).with_traceback(traceback)

        # Wait for a short period before checking the status again
        await asyncio.sleep(logs_refresh_frequency)

    raise BuildException("Unknown build error occurred.")


async def check_alias_exists(client: AuthenticatedClient, alias: str) -> bool:
    """
    Check if a template with the given alias exists.

    Args:
        client: Authenticated API client
        alias: Template alias to check

    Returns:
        True if the alias exists, False otherwise
    """
    res = await get_templates_aliases_alias.asyncio_detailed(
        alias=alias,
        client=client,
    )

    # If we get a NotFound, the alias doesn't exist
    if res.status_code == 404:
        return False

    # If we get a Forbidden, alias exists, but you are not owner
    if res.status_code == 403:
        return True

    # Handle other errors
    if res.status_code >= 300:
        raise handle_api_exception(res, TemplateException)

    # If we get Ok with data, you are owner and the alias exists
    return res.parsed is not None


async def assign_tags(
    client: AuthenticatedClient, target_name: str, tags: List[str]
) -> TemplateTagInfo:
    """
    Assign tag(s) to an existing template build.

    Args:
        client: Authenticated API client
        target_name: Template name in 'name:tag' format (the source build to tag from)
        tags: Tags to assign

    Returns:
        TemplateTagInfo with build_id and assigned tags
    """
    res = await post_templates_tags.asyncio_detailed(
        client=client,
        body=AssignTemplateTagsRequest(
            target=target_name,
            tags=tags,
        ),
    )

    if res.status_code >= 300:
        raise handle_api_exception(res, TemplateException)

    if isinstance(res.parsed, Error):
        raise TemplateException(f"API error: {res.parsed.message}")

    if res.parsed is None:
        raise TemplateException("Failed to assign tags")

    return TemplateTagInfo(
        build_id=str(res.parsed.build_id),
        tags=res.parsed.tags,
    )


async def remove_tags(client: AuthenticatedClient, name: str, tags: List[str]) -> None:
    """
    Remove tag(s) from a template.

    Args:
        client: Authenticated API client
        name: Template name
        tags: List of tags to remove
    """
    res = await delete_templates_tags.asyncio_detailed(
        client=client,
        body=DeleteTemplateTagsRequest(
            name=name,
            tags=tags,
        ),
    )

    if res.status_code >= 300:
        raise handle_api_exception(res, TemplateException)


async def get_template_tags(
    client: AuthenticatedClient, template_id: str
) -> List[TemplateTag]:
    """
    Get all tags for a template.

    Args:
        client: Authenticated API client
        template_id: Template ID or name
    """
    res = await get_templates_template_id_tags.asyncio_detailed(
        template_id=template_id,
        client=client,
    )

    if res.status_code >= 300:
        raise handle_api_exception(res, TemplateException)

    if isinstance(res.parsed, Error):
        raise TemplateException(f"API error: {res.parsed.message}")

    if res.parsed is None:
        raise TemplateException("Failed to get template tags")

    return [
        TemplateTag(
            tag=item.tag,
            build_id=str(item.build_id),
            created_at=item.created_at,
        )
        for item in res.parsed
    ]
