from fastapi import HTTPException
from fastapi import status
from httpx import AsyncClient
from httpx import TimeoutException
from packaging.version import InvalidVersion
from packaging.version import Version
from sqlmodel import func
from sqlmodel import select

from fractal_server.app.db import AsyncSession
from fractal_server.app.models.v2 import JobV2
from fractal_server.app.models.v2 import TaskGroupActivityV2
from fractal_server.app.models.v2 import TaskGroupV2
from fractal_server.app.models.v2 import TaskV2
from fractal_server.app.models.v2 import WorkflowTaskV2
from fractal_server.app.models.v2 import WorkflowV2
from fractal_server.app.schemas.v2 import JobStatusTypeV2
from fractal_server.app.schemas.v2 import TaskGroupActivityStatusV2
from fractal_server.logger import set_logger
from fractal_server.tasks.v2.utils_package_names import normalize_package_name

# See https://packaging.python.org/en/latest/specifications/simple-repository-api/#content-types  # noqa
PYPI_JSON_HEADERS = {"Accept": "application/vnd.pypi.simple.v1+json"}


logger = set_logger(__name__)


def _find_latest_version_or_422(versions: list[str]) -> str:
    """
    > For PEP 440 versions, this is easy enough for the client to do (using
    > the `packaging` library [...]. For non-standard versions, there is no
    > well-defined ordering, and clients will need to decide on what rule is
    > appropriate for their needs.
    (https://peps.python.org/pep-0700/#why-not-provide-a-latest-version-value)
    """
    try:
        latest = max(versions, key=lambda v_str: Version(v_str))
        return latest
    except InvalidVersion as e:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail=f"Cannot find latest version (original error: {str(e)}).",
        )


async def get_package_version_from_pypi(
    name: str,
    version: str | None = None,
) -> str:
    """
    Make a GET call to PyPI JSON API and get latest *compatible* version.

    There are three cases:

    1. `version` is set and it is found on PyPI as-is.
    2. `version` is set but it is not found on PyPI as-is.
    3. `version` is unset, and we query `PyPI` for latest.

    Ref https://warehouse.pypa.io/api-reference/json.html.

    Args:
        name: Package name.
        version:
            Could be a correct version (`1.3.0`), an incomplete one
            (`1.3`) or `None`.
    """
    normalized_name = normalize_package_name(name)
    url = f"https://pypi.org/simple/{normalized_name}/"
    hint = f"Hint: specify the required version for '{name}'."

    # Make request to PyPI
    try:
        async with AsyncClient(timeout=5.0) as client:
            res = await client.get(url, headers=PYPI_JSON_HEADERS)
    except TimeoutException as e:
        error_msg = (
            f"A TimeoutException occurred while getting {url}.\n"
            f"Original error: {str(e)}."
        )
        logger.warning(error_msg)
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail=error_msg,
        )
    except BaseException as e:
        error_msg = (
            f"An unknown error occurred while getting {url}. "
            f"Original error: {str(e)}."
        )
        logger.warning(error_msg)
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail=error_msg,
        )

    # Parse response
    if res.status_code != 200:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail=(
                f"Could not get {url} (status_code {res.status_code}).\n{hint}"
            ),
        )
    try:
        response_data = res.json()
        available_releases = response_data["versions"]
        latest_version = _find_latest_version_or_422(available_releases)
    except KeyError as e:
        logger.warning(
            f"A KeyError occurred while getting {url}. "
            f"Original error: {str(e)}."
        )
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail=f"A KeyError error occurred while getting {url}.\n{hint}",
        )

    logger.info(
        f"Obtained data from {url}: "
        f"{len(available_releases)} releases, "
        f"latest={latest_version}."
    )

    if version is not None:
        if version in available_releases:
            logger.info(f"Requested {version=} available on PyPI.")
            # Case 1: `version` is set and it is found on PyPI as-is
            return version
        else:
            # Case 2: `version` is set but it is not found on PyPI as-is
            # Filter using `version` as prefix, and sort
            matching_versions = [
                v for v in available_releases if v.startswith(version)
            ]
            logger.info(
                f"Requested {version=} not available on PyPI, "
                f"found {len(matching_versions)} versions matching "
                f"`{version}*`."
            )
            if len(matching_versions) == 0:
                logger.info(f"No version starting with {version} found.")
                raise HTTPException(
                    status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                    detail=(
                        f"No version starting with {version} found.\n{hint}"
                    ),
                )
            else:
                latest_matching_version = sorted(matching_versions)[-1]
                return latest_matching_version
    else:
        # Case 3: `version` is unset and we use latest
        logger.info(f"No version requested, returning {latest_version=}.")
        return latest_version


async def check_no_ongoing_activity(
    *,
    task_group_id: int,
    db: AsyncSession,
) -> None:
    """
    Find ongoing activities for the same task group.

    Args:
        task_group_id:
        db:
    """
    # DB query
    stm = (
        select(TaskGroupActivityV2)
        .where(TaskGroupActivityV2.taskgroupv2_id == task_group_id)
        .where(TaskGroupActivityV2.status == TaskGroupActivityStatusV2.ONGOING)
    )
    res = await db.execute(stm)
    ongoing_activities = res.scalars().all()

    if ongoing_activities == []:
        # All good, exit
        return

    msg = "Found ongoing activities for the same task-group:"
    for ind, activity in enumerate(ongoing_activities):
        msg = (
            f"{msg}\n{ind + 1}) "
            f"Action={activity.action}, "
            f"status={activity.status}, "
            f"timestamp_started={activity.timestamp_started}."
        )
    raise HTTPException(
        status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
        detail=msg,
    )


async def check_no_submitted_job(
    *,
    task_group_id: int,
    db: AsyncSession,
) -> None:
    """
    Find submitted jobs which include tasks from a given task group.

    Args:
        task_group_id: ID of the `TaskGroupV2` object.
        db: Asynchronous database session.
    """
    stm = (
        select(func.count(JobV2.id))
        .join(WorkflowV2, JobV2.workflow_id == WorkflowV2.id)
        .join(WorkflowTaskV2, WorkflowTaskV2.workflow_id == WorkflowV2.id)
        .join(TaskV2, WorkflowTaskV2.task_id == TaskV2.id)
        .where(WorkflowTaskV2.order >= JobV2.first_task_index)
        .where(WorkflowTaskV2.order <= JobV2.last_task_index)
        .where(JobV2.status == JobStatusTypeV2.SUBMITTED)
        .where(TaskV2.taskgroupv2_id == task_group_id)
    )
    res = await db.execute(stm)
    num_submitted_jobs = res.scalar()
    if num_submitted_jobs > 0:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail=(
                f"Cannot act on task group because {num_submitted_jobs} "
                "submitted jobs use its tasks."
            ),
        )


async def check_no_related_workflowtask(
    *,
    task_group: TaskGroupV2,
    db: AsyncSession,
) -> None:
    """
    Raises an HTTPException if any of the tasks in the TaskGroup are referenced
    by an existing WorkflowTask.
    """
    stm = select(WorkflowTaskV2).where(
        WorkflowTaskV2.task_id.in_([task.id for task in task_group.task_list])
    )
    res = await db.execute(stm)
    bad_wftask = res.scalars().first()
    if bad_wftask is not None:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail=f"TaskV2 {bad_wftask.task_id} is still in use",
        )
