from __future__ import annotations

import asyncio
from typing import Any, Awaitable, Callable, Iterable, List, TypeVar

T = TypeVar("T")
R = TypeVar("R")


async def parallel(
    func: Callable[[T], Awaitable[R]] | Callable[[T], R],
    items: Iterable[T],
    *,
    concurrency: int = 5,
) -> List[R]:
    """Run `func` over `items` concurrently with a max concurrency limit.

    - If `func` is sync, it runs in the default executor.
    - Results preserve input order.
    """
    semaphore = asyncio.Semaphore(concurrency)

    async def run_one(idx_item: tuple[int, T]) -> tuple[int, R]:
        idx, item = idx_item
        async with semaphore:
            res = func(item)
            if asyncio.iscoroutine(res):
                out = await res  # type: ignore[assignment]
            else:
                loop = asyncio.get_running_loop()
                out = await loop.run_in_executor(None, lambda: res)  # type: ignore[assignment]
            return idx, out

    tasks = [asyncio.create_task(run_one(x)) for x in enumerate(items)]
    gathered = await asyncio.gather(*tasks)
    # sort by original index to preserve order
    gathered.sort(key=lambda t: t[0])
    return [r for _, r in gathered]
