"""
Exercise 2: bounded concurrency and timeouts with asyncio (medium)

call_api(i) is a coroutine simulating an API call (0.1 s). It records how many calls run at once.

Implement:
  - fetch_many(ids, limit): run call_api for every id concurrently but with at most `limit` in flight
    (asyncio.Semaphore), and return results in the order of ids (asyncio.gather keeps order).
  - fetch_with_timeout(coro_factory, timeout): await coro_factory() with asyncio.wait_for; on timeout
    return the string "timeout" instead of raising.
Python 3.9 compatible: don't use asyncio.TaskGroup or asyncio.timeout.
"""

import asyncio
import time

state = {"running": 0, "max": 0}


async def call_api(i):
    state["running"] += 1
    state["max"] = max(state["max"], state["running"])
    await asyncio.sleep(0.1)
    state["running"] -= 1
    return i * 2


async def fetch_many(ids, limit):
    return [await call_api(i) for i in ids]  # TODO: concurrent with a semaphore


async def fetch_with_timeout(coro_factory, timeout):
    return await coro_factory()  # TODO


# --- self-check: do not modify below this line ---
checks = []
start = time.perf_counter()
res = asyncio.run(fetch_many(list(range(20)), 5))
elapsed = time.perf_counter() - start
checks.append(("results in order", res, [i * 2 for i in range(20)]))
checks.append(("at most 5 in flight", state["max"], 5))
checks.append(("concurrent (< 1 s)", elapsed < 1.0, True))


async def slow():
    await asyncio.sleep(1)
    return "done"


async def fast():
    await asyncio.sleep(0.01)
    return "done"

checks.append(("timeout returns marker", asyncio.run(fetch_with_timeout(slow, 0.1)), "timeout"))
checks.append(("fast completes", asyncio.run(fetch_with_timeout(fast, 0.5)), "done"))

passed = 0
for label, actual, expected in checks:
    ok = actual == expected
    passed += ok
    print(f"{'PASS' if ok else 'FAIL'}: {label} (got {actual!r}, expected {expected!r})")

print("PASS" if passed == len(checks) else "FAIL")
