import hashlib
import html
import itertools
import os
from pathlib import Path
import re
import threading
import typing
from urllib.parse import quote

from starlette.concurrency import run_in_threadpool
from starlette.requests import Request
from starlette.responses import FileResponse, PlainTextResponse, Response

from . import config

PEP503_NORMALIZE_RE = re.compile(r"[-_.]+")

WheelIndex = typing.Dict[str, typing.List[Path]]
_wheel_index_cache: typing.Dict[str, WheelIndex] = {}
_wheel_index_cache_lock = threading.Lock()


def normalize_project_name(name: str) -> str:
    return PEP503_NORMALIZE_RE.sub("-", name).lower()


def _wheel_distribution_name(filename: str) -> typing.Optional[str]:
    if not filename.endswith(".whl"):
        return None
    parts = filename[:-4].split("-")
    if len(parts) < 5:
        return None
    return parts[0]


def _is_child_of(path: Path, parent: Path) -> bool:
    path_string = os.path.abspath(os.path.normpath(path))
    parent_string = os.path.abspath(os.path.normpath(parent))
    try:
        return os.path.commonpath([path_string, parent_string]) == parent_string
    except ValueError:
        return False


def _build_wheel_index(root_dir: Path) -> WheelIndex:
    index: WheelIndex = {}
    if not root_dir.is_dir():
        return index
    # Scan wheels in root or one directory below root.
    wheel_paths = itertools.chain(root_dir.glob("*.whl"), root_dir.glob("*/*.whl"))
    for wheel_path in wheel_paths:
        wheel_path_resolved = wheel_path.resolve(strict=False)
        if not _is_child_of(wheel_path_resolved, root_dir):
            continue
        dist_name = _wheel_distribution_name(wheel_path.name)
        if dist_name is None:
            continue
        normalized_dist_name = normalize_project_name(dist_name)
        index.setdefault(normalized_dist_name, []).append(wheel_path)
    for wheels in index.values():
        wheels.sort(key=lambda p: p.name)
    return index


def _get_wheel_index(root_dir: Path) -> WheelIndex:
    root_dir = root_dir.resolve(strict=False)
    root_key = os.fspath(root_dir)

    with _wheel_index_cache_lock:
        cached = _wheel_index_cache.get(root_key)
        if cached is not None:
            return cached

    # Expensive filesystem scan must happen outside the cache lock.
    wheel_index = _build_wheel_index(root_dir)

    with _wheel_index_cache_lock:
        cached = _wheel_index_cache.get(root_key)
        if cached is not None:
            return cached
        _wheel_index_cache[root_key] = wheel_index
        return wheel_index


def _build_project_response_body(
    package: str, wheel_root: Path
) -> typing.Optional[str]:
    normalized_package = normalize_project_name(package)
    wheel_index = _get_wheel_index(wheel_root)
    wheels = list(wheel_index.get(normalized_package, []))
    if not wheels:
        return None

    links = []
    for wheel_path in wheels:
        relative_wheel_path = wheel_path.relative_to(wheel_root).as_posix()
        quoted_path = quote(relative_wheel_path, safe="/")
        wheel_url = f"/api/simple-file/{normalized_package}/{quoted_path}"
        wheel_hash = _sha256_of_file(wheel_path)
        link_text = html.escape(wheel_path.name)
        link_href = html.escape(f"{wheel_url}#sha256={wheel_hash}")
        links.append(f'<a href="{link_href}">{link_text}</a>')

    return "<!DOCTYPE html><html><body>\n" + "\n".join(links) + "\n</body></html>\n"


def _sha256_of_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as f:
        while True:
            block = f.read(1024 * 1024)
            if not block:
                break
            digest.update(block)
    return digest.hexdigest()


async def simple_file_project(request: Request):
    package = request.path_params["package"]
    wheel_root = Path(config.PYCAFE_SERVER_SIMPLE_FILE_DIR).resolve(strict=False)
    body = await run_in_threadpool(_build_project_response_body, package, wheel_root)
    if body is None:
        return PlainTextResponse(
            f"No wheels found for package '{package}' in '{wheel_root}'",
            status_code=404,
        )
    return Response(body, media_type="text/html")


async def simple_file_wheel(request: Request):
    package = request.path_params["package"]
    wheel = request.path_params["wheel"]
    wheel_root = Path(config.PYCAFE_SERVER_SIMPLE_FILE_DIR).resolve(strict=False)
    wheel_path = (wheel_root / wheel).resolve(strict=False)

    if not _is_child_of(wheel_path, wheel_root):
        return PlainTextResponse("Invalid wheel path", status_code=400)
    if not wheel_path.is_file():
        return PlainTextResponse("Wheel not found", status_code=404)

    dist_name = _wheel_distribution_name(wheel_path.name)
    if dist_name is None or normalize_project_name(dist_name) != normalize_project_name(
        package
    ):
        return PlainTextResponse("Wheel does not match package", status_code=404)

    return FileResponse(wheel_path, filename=wheel_path.name)
