import asyncio
import os
import random
import string
from typing import Callable, Dict, Optional

import pytest
import pytest_asyncio

from e2b import (
    AsyncCommandHandle,
    AsyncSandbox,
    AsyncTemplate,
    AsyncVolume,
    CommandExitException,
    CommandHandle,
    LogEntry,
    Sandbox,
    Template,
    TemplateClass,
    Volume,
)


@pytest.fixture
def test_api_key() -> str:
    """Placeholder API key with a valid format for tests that don't hit the API."""
    return "e2b_" + "0" * 40


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    rep = outcome.get_result()
    if rep.when == "call":
        item._test_failed = rep.failed


@pytest.fixture()
def sandbox_test_id():
    return f"test_{_generate_random_string()}"


@pytest.fixture()
def template():
    return "base"


@pytest.fixture()
def sandbox_factory(request, template, sandbox_test_id):
    def factory(*, template_name: str = template, **kwargs):
        metadata = kwargs.setdefault("metadata", dict())
        metadata.setdefault("sandbox_test_id", sandbox_test_id)

        sandbox = Sandbox.create(template_name, **kwargs)

        def finalizer():
            if getattr(request.node, "_test_failed", False):
                print(f"\n[TEST FAILED] Sandbox ID: {sandbox.sandbox_id}")
            sandbox.kill()

        request.addfinalizer(finalizer)

        return sandbox

    return factory


@pytest.fixture()
def sandbox(sandbox_factory):
    return sandbox_factory()


@pytest_asyncio.fixture
async def async_sandbox_factory(request, template, sandbox_test_id):
    sandboxes: list = []

    async def factory(*, template_name: str = template, **kwargs):
        metadata = kwargs.setdefault("metadata", dict())
        metadata.setdefault("sandbox_test_id", sandbox_test_id)

        sandbox = await AsyncSandbox.create(template_name, **kwargs)
        sandboxes.append(sandbox)
        return sandbox

    yield factory

    if getattr(request.node, "_test_failed", False):
        for sandbox in sandboxes:
            print(f"\n[TEST FAILED] Sandbox ID: {sandbox.sandbox_id}")

    results = await asyncio.gather(
        *(sandbox.kill() for sandbox in sandboxes), return_exceptions=True
    )
    for sandbox, result in zip(sandboxes, results):
        if isinstance(result, BaseException):
            print(f"\n[TEARDOWN FAILED] Sandbox ID: {sandbox.sandbox_id}: {result!r}")


@pytest_asyncio.fixture
async def async_sandbox(async_sandbox_factory):
    return await async_sandbox_factory()


@pytest.fixture
def build():
    def _build(
        template: TemplateClass,
        name: Optional[str] = None,
        skip_cache: bool = False,
        on_build_logs: Optional[Callable[[LogEntry], None]] = None,
    ):
        build_name = name or f"e2b-test-{_generate_random_string()}"
        build_info: Dict[str, Optional[str]] = {"template_id": None, "build_id": None}

        def capture_logs(log: LogEntry):
            import re

            if "Template created with ID:" in log.message:
                match = re.search(
                    r"Template created with ID: ([^,]+), Build ID: (.+)", log.message
                )
                if match:
                    build_info["template_id"] = match.group(1)
                    build_info["build_id"] = match.group(2)
            if on_build_logs:
                on_build_logs(log)

        try:
            return Template.build(
                template,
                build_name,
                cpu_count=1,
                memory_mb=1024,
                skip_cache=skip_cache,
                on_build_logs=capture_logs,
            )
        except Exception as e:
            print(
                f"\n[BUILD FAILED] name={build_name}, "
                f"template_id={build_info['template_id']}, "
                f"build_id={build_info['build_id']}, error={e}"
            )
            raise

    return _build


@pytest_asyncio.fixture
def async_build():
    async def _async_build(
        template: TemplateClass,
        name: Optional[str] = None,
        skip_cache: bool = False,
        on_build_logs: Optional[Callable[[LogEntry], None]] = None,
    ):
        build_name = name or f"e2b-test-{_generate_random_string()}"
        build_info: Dict[str, Optional[str]] = {"template_id": None, "build_id": None}

        def capture_logs(log: LogEntry):
            import re

            if "Template created with ID:" in log.message:
                match = re.search(
                    r"Template created with ID: ([^,]+), Build ID: (.+)", log.message
                )
                if match:
                    build_info["template_id"] = match.group(1)
                    build_info["build_id"] = match.group(2)
            if on_build_logs:
                on_build_logs(log)

        try:
            return await AsyncTemplate.build(
                template,
                build_name,
                cpu_count=1,
                memory_mb=1024,
                skip_cache=skip_cache,
                on_build_logs=capture_logs,
            )
        except Exception as e:
            print(
                f"\n[BUILD FAILED] name={build_name}, "
                f"template_id={build_info['template_id']}, "
                f"build_id={build_info['build_id']}, error={e}"
            )
            raise

    return _async_build


@pytest.fixture
def debug():
    return os.getenv("E2B_DEBUG") is not None


@pytest.fixture(autouse=True)
def skip_by_debug(request, debug):
    if request.node.get_closest_marker("skip_debug"):
        if debug:
            pytest.skip("skipped because E2B_DEBUG is set")


class Helpers:
    @staticmethod
    def catch_cmd_exit_error_in_background(cmd: AsyncCommandHandle):
        disabled = False

        async def wait_for_exit():
            try:
                await cmd.wait()
            except CommandExitException as e:
                if not disabled:
                    assert False, (
                        f"command failed with exit code {e.exit_code}: {e.stderr}"
                    )

        asyncio.create_task(wait_for_exit())

        def disable():
            nonlocal disabled
            disabled = True

        return disable

    @staticmethod
    def check_cmd_exit_error(cmd: CommandHandle):
        try:
            cmd.wait()
        except CommandExitException as e:
            assert False, f"command failed with exit code {e.exit_code}: {e.stderr}"
        except Exception as e:
            raise e


@pytest.fixture
def helpers():
    return Helpers


def _generate_random_string(length: int = 8) -> str:
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))


def _skip_unless_volume_tests_enabled():
    if os.getenv("ENABLE_VOLUME_TESTS") is None:
        pytest.skip("skipped because ENABLE_VOLUME_TESTS is not set")


@pytest.fixture
def volume(request):
    _skip_unless_volume_tests_enabled()
    vol = Volume.create(f"test-vol-{_generate_random_string()}")

    def finalizer():
        if getattr(request.node, "_test_failed", False):
            print(f"\n[TEST FAILED] Volume ID: {vol.volume_id}")
        try:
            Volume.destroy(vol.volume_id)
        except Exception:
            pass

    request.addfinalizer(finalizer)
    return vol


@pytest_asyncio.fixture
async def async_volume(request):
    _skip_unless_volume_tests_enabled()
    vol = await AsyncVolume.create(f"test-vol-{_generate_random_string()}")
    try:
        yield vol
    finally:
        if getattr(request.node, "_test_failed", False):
            print(f"\n[TEST FAILED] Volume ID: {vol.volume_id}")
        try:
            await AsyncVolume.destroy(vol.volume_id)
        except Exception:
            pass
