# type: ignore
import hashlib
import os
import sys
from functools import wraps
from typing import Optional, List, Dict, Any

from packaging.version import parse as parse_version
from packaging.specifiers import SpecifierSet
from pathlib import Path

import click

# Jinja2 will only be installed if the optional deps are installed.
# It's fine if our functions fail, but don't let this top level
# import error out.
from safety.models import is_pinned_requirement
from safety.output_utils import (
    get_unpinned_hint,
    get_specifier_range_info,
    get_fix_hint_for_unpinned,
)

try:
    import jinja2
except ImportError:
    jinja2 = None

import requests
from safety.meta import get_meta_http_headers


def highest_base_score(vulns: List[Dict[str, Any]]) -> float:
    """
    Calculates the highest CVSS base score from a list of vulnerabilities.

    Args:
        vulns (List[Dict[str, Any]]): The list of vulnerabilities.

    Returns:
        float: The highest CVSS base score.
    """
    highest_base_score = 0
    for vuln in vulns:
        if vuln["severity"] is not None:
            highest_base_score = max(
                highest_base_score,
                (vuln["severity"].get("cvssv3", {}) or {}).get("base_score", 10),
            )

    return highest_base_score


def generate_branch_name(pkg: str, remediation: Dict[str, Any]) -> str:
    """
    Generates a branch name for a given package and remediation.

    Args:
        pkg (str): The package name.
        remediation (Dict[str, Any]): The remediation data.

    Returns:
        str: The generated branch name.
    """
    return f"{pkg}/{remediation['requirement']['specifier']}/{remediation['recommended_version']}"


def generate_issue_title(pkg: str, remediation: Dict[str, Any]) -> str:
    """
    Generates an issue title for a given package and remediation.

    Args:
        pkg (str): The package name.
        remediation (Dict[str, Any]): The remediation data.

    Returns:
        str: The generated issue title.
    """
    return f"Security Vulnerability in {pkg}{remediation['requirement']['specifier']}"


def get_hint(remediation: Dict[str, Any]) -> str:
    """
    Generates a hint for a given remediation.

    Args:
        remediation (Dict[str, Any]): The remediation data.

    Returns:
        str: The generated hint.
    """
    pinned = is_pinned_requirement(
        SpecifierSet(remediation["requirement"]["specifier"])
    )
    hint = ""

    if not pinned:
        fix_hint = get_fix_hint_for_unpinned(remediation)
        hint = (
            f"{fix_hint}\n\n{get_unpinned_hint(remediation['requirement']['name'])} "
            f"{get_specifier_range_info(style=False)}"
        )

    return hint


def generate_title(
    pkg: str, remediation: Dict[str, Any], vulns: List[Dict[str, Any]]
) -> str:
    """
    Generates a title for a pull request or issue.

    Args:
        pkg (str): The package name.
        remediation (Dict[str, Any]): The remediation data.
        vulns (List[Dict[str, Any]]): The list of vulnerabilities.

    Returns:
        str: The generated title.
    """
    suffix = "y" if len(vulns) == 1 else "ies"
    from_dependency = (
        remediation["version"]
        if remediation["version"]
        else remediation["requirement"]["specifier"]
    )
    return f"Update {pkg} from {from_dependency} to {remediation['recommended_version']} to fix {len(vulns)} vulnerabilit{suffix}"


def generate_body(
    pkg: str, remediation: Dict[str, Any], vulns: List[Dict[str, Any]], *, api_key: str
) -> Optional[str]:
    """
    Generates the body content for a pull request.

    Args:
        pkg (str): The package name.
        remediation (Dict[str, Any]): The remediation data.
        vulns (List[Dict[str, Any]]): The list of vulnerabilities.
        api_key (str): The API key for fetching changelog data.

    Returns:
        str: The generated body content.
    """
    changelog = fetch_changelog(
        pkg,
        remediation["version"],
        remediation["recommended_version"],
        api_key=api_key,
        from_spec=remediation.get("requirement", {}).get("specifier", None),
    )

    p = Path(__file__).parent / "templates"
    env = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(p)))  # type: ignore
    template = env.get_template("pr.jinja2")

    overall_impact = cvss3_score_to_label(highest_base_score(vulns))

    context = {
        "pkg": pkg,
        "remediation": remediation,
        "vulns": vulns,
        "changelog": changelog,
        "overall_impact": overall_impact,
        "summary_changelog": False,
        "hint": get_hint(remediation),
    }

    result = template.render(context)

    # GitHub has a PR body length limit of 65536. If we're going over that, skip the changelog and just use a link.
    if len(result) < 65500:
        return result

    context["summary_changelog"] = True

    return template.render(context)


def generate_issue_body(
    pkg: str, remediation: Dict[str, Any], vulns: List[Dict[str, Any]], *, api_key: str
) -> Optional[str]:
    """
    Generates the body content for an issue.

    Args:
        pkg (str): The package name.
        remediation (Dict[str, Any]): The remediation data.
        vulns (List[Dict[str, Any]]): The list of vulnerabilities.
        api_key (str): The API key for fetching changelog data.

    Returns:
        str: The generated body content.
    """
    changelog = fetch_changelog(
        pkg,
        remediation["version"],
        remediation["recommended_version"],
        api_key=api_key,
        from_spec=remediation.get("requirement", {}).get("specifier", None),
    )

    p = Path(__file__).parent / "templates"
    env = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(p)))  # type: ignore
    template = env.get_template("issue.jinja2")

    overall_impact = cvss3_score_to_label(highest_base_score(vulns))

    context = {
        "pkg": pkg,
        "remediation": remediation,
        "vulns": vulns,
        "changelog": changelog,
        "overall_impact": overall_impact,
        "summary_changelog": False,
        "hint": get_hint(remediation),
    }

    result = template.render(context)

    # GitHub has a PR body length limit of 65536. If we're going over that, skip the changelog and just use a link.
    if len(result) < 65500:
        return result

    context["summary_changelog"] = True

    return template.render(context)


def generate_commit_message(pkg: str, remediation: Dict[str, Any]) -> str:
    """
    Generates a commit message for a given package and remediation.

    Args:
        pkg (str): The package name.
        remediation (Dict[str, Any]): The remediation data.

    Returns:
        str: The generated commit message.
    """
    from_dependency = (
        remediation["version"]
        if remediation["version"]
        else remediation["requirement"]["specifier"]
    )

    return (
        f"Update {pkg} from {from_dependency} to {remediation['recommended_version']}"
    )


def git_sha1(raw_contents: bytes) -> str:
    """
    Calculates the SHA-1 hash of the given raw contents.

    Args:
        raw_contents (bytes): The raw contents to hash.

    Returns:
        str: The SHA-1 hash.
    """
    return hashlib.sha1(
        b"blob " + str(len(raw_contents)).encode("ascii") + b"\0" + raw_contents
    ).hexdigest()


def fetch_changelog(
    package: str,
    from_version: Optional[str],
    to_version: str,
    *,
    api_key: str,
    from_spec: Optional[str] = None,
) -> Dict[str, Any]:
    """
    Fetches the changelog for a package from a specified version to another version.

    Args:
        package (str): The package name.
        from_version (Optional[str]): The starting version.
        to_version (str): The ending version.
        api_key (str): The API key for fetching changelog data.
        from_spec (Optional[str]): The specifier for the starting version.

    Returns:
        Dict[str, Any]: The fetched changelog data.
    """
    to_version_parsed = parse_version(to_version)

    if from_version:
        from_version_parsed = parse_version(from_version)
    else:
        from_version_parsed = None
        from_spec = SpecifierSet(from_spec)

    changelog = {}

    headers = {"X-Api-Key": api_key}
    headers.update(get_meta_http_headers())

    r = requests.get(
        "https://pyup.io/api/v1/changelogs/{}/".format(package), headers=headers
    )

    if r.status_code == 200:
        data = r.json()
        if data:
            # sort the changelog by release
            sorted_log = sorted(
                data.items(), key=lambda v: parse_version(v[0]), reverse=True
            )

            # go over each release and add it to the log if it's within the "upgrade
            # range" e.g. update from 1.2 to 1.3 includes a changelog for 1.2.1 but
            # not for 0.4.
            for version, log in sorted_log:
                parsed_version = parse_version(version)
                version_check = from_version and (parsed_version > from_version_parsed)
                spec_check = (
                    from_spec
                    and isinstance(from_spec, SpecifierSet)
                    and from_spec.contains(parsed_version)
                )

                if version_check or spec_check and parsed_version <= to_version_parsed:
                    changelog[version] = log

    return changelog


def cvss3_score_to_label(score: float) -> Optional[str]:
    """
    Converts a CVSS v3 score to a severity label.

    Args:
        score (float): The CVSS v3 score.

    Returns:
        Optional[str]: The severity label.
    """
    if 0.1 <= score <= 3.9:
        return "low"
    elif 4.0 <= score <= 6.9:
        return "medium"
    elif 7.0 <= score <= 8.9:
        return "high"
    elif 9.0 <= score <= 10.0:
        return "critical"

    return None


def require_files_report(func):
    @wraps(func)
    def inner(obj: Any, *args: Any, **kwargs: Any) -> Any:
        """
        Decorator that ensures a report is generated against a file.

        Args:
            obj (Any): The object containing the report.
            *args (Any): Additional arguments.
            **kwargs (Any): Additional keyword arguments.

        Returns:
            Any: The result of the decorated function.
        """
        if obj.report["report_meta"]["scan_target"] != "files":
            click.secho(
                "This report was generated against an environment, but this alert command requires "
                "a scan report that was generated against a file. To learn more about the "
                "`safety alert` command visit https://docs.pyup.io/docs/safety-2-alerts",
                fg="red",
            )
            sys.exit(1)

        files = obj.report["report_meta"]["scanned"]
        obj.requirements_files = {}
        for f in files:
            if not os.path.exists(f):
                cwd = os.getcwd()
                click.secho(
                    "A requirements file scanned in the report, {}, does not exist (looking in {}).".format(
                        f, cwd
                    ),
                    fg="red",
                )
                sys.exit(1)

            obj.requirements_files[f] = open(f, "rb").read()

        return func(obj, *args, **kwargs)

    return inner
