# -*- coding: utf-8 -*-
from __future__ import annotations

import collections
import fnmatch
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from inspect import signature
from statistics import median
from typing import Dict, List, NewType, Optional, Tuple

import jsone
import requests
import taskcluster
import yaml
from loguru import logger

from mozci import config, data
from mozci.errors import ArtifactNotFound, TaskNotFound
from mozci.util.defs import INTERMITTENT_CLASSES
from mozci.util.memoize import memoize, memoized_property
from mozci.util.taskcluster import (
    PRODUCTION_TASKCLUSTER_ROOT_URL,
    find_task_id,
    get_artifact,
    list_artifacts,
)


class Status(Enum):
    PASS = 0
    FAIL = 1
    INTERMITTENT = 2


@memoize
def fetch_test_variant_yaml() -> Dict:
    # load variants.yml from mozilla-central
    test_variants = {}
    try:
        r = requests.get(
            "https://hg.mozilla.org/mozilla-central/raw-file/default/taskcluster/test_configs/variants.yml"
        )
        tv = yaml.load(r.text, yaml.BaseLoader)
        for v in tv:
            test_variants[v] = tv[v]["suffix"]
    except requests.ConnectionError:
        raise
    return test_variants


NO_GROUPS_SUITES = (
    "raptor",
    "talos",
    "awsy",
    "gtest",
    "cppunit",
    "telemetry-tests",
    "firefox-ui-functional",
    "junit",  # https://bugzilla.mozilla.org/show_bug.cgi?id=1617632
    "jittest",  # https://bugzilla.mozilla.org/show_bug.cgi?id=1617633
    "marionette",  # https://bugzilla.mozilla.org/show_bug.cgi?id=1636088
)


def is_no_groups_suite(label):
    return any(f"-{s}-" in label for s in NO_GROUPS_SUITES)


slash_group_warned = False


def is_bad_group(task_id: str, group: str) -> bool:
    bad_group = (
        not group.strip()
        or group.startswith("file://")
        or group.startswith("Z:")
        or os.path.isabs(group)
        or "\\" in group
    )

    if bad_group:
        logger.error(f"Bad group name in task {task_id}: '{group}'")

    return bad_group


def is_autoclassifiable(task: TestTask) -> bool:
    """Check a task is enabled for auto-classification
    by applying glob patterns from configuration
    """
    assert task.label, "Missing task label"

    if not config["autoclassification"]["enabled"]:
        return False

    allowed_values = set(failure_type.value for failure_type in FailureType)
    filtered_failure_types = config["autoclassification"]["failure-types"]
    assert isinstance(filtered_failure_types, list) and set(
        filtered_failure_types
    ).issubset(allowed_values), "Unsupported failure types in configuration"

    flat_failure_types = list(
        set(
            test_and_type
            for group in task.failure_types.values()
            for test_and_type in group
        )
    )

    return (
        any(
            fnmatch.fnmatch(task.label, pattern)
            for pattern in config["autoclassification"]["test-suite-names"]
        )
        and len(flat_failure_types) == 1
        and flat_failure_types[0][1].value in filtered_failure_types
    )


# Transform WPT group names to a full relative path in mozilla-central.
def wpt_workaround(group: str) -> str:
    # No need to transform empty groups (also, they will be filtered out
    # in a following step).
    if not group.strip():
        return group

    if group.startswith(":"):
        assert group.startswith(":/"), (
            f"Group {group} starts with : but doesn't start with :/"
        )
        group = group[1:]
    assert group.startswith("/"), f"Group {group} doesn't start with /"
    if group.startswith("/_mozilla/"):
        return "/".join(
            ["testing/web-platform/mozilla/tests", group[len("/_mozilla/") :]]
        )
    else:
        return "/".join(["testing/web-platform/tests", group[1:]])


def get_test_variant(variants, label) -> str:
    if not variants:
        return ""

    suffixes = []
    yaml_variants = fetch_test_variant_yaml()
    for variant in variants:
        if variant not in yaml_variants:
            raise Exception(
                f"Missing test variant {variant} in variants.yml: {yaml_variants}"
            )
        suffixes.append(yaml_variants[variant])

    # figure out which order to pack the suffixes; we can cheat by using self.label
    retVal = sorted(suffixes, key=lambda x: label.find(x))
    return "-".join(retVal)


def get_configuration(task) -> str:
    platform = task["task"]["extra"]["treeherder-platform"]
    suite = task["task"]["extra"]["suite"]
    label = task["label"]
    test_variant = get_test_variant(
        task["task"]["extra"].get("test-settings", {}).get("runtime", {}), label
    )
    return _get_configuration(platform, suite, label, test_variant)


def _get_configuration(platform, suite, label, test_variant) -> str:
    assert platform is not None
    retVal = f"test-{platform}"

    # Hack #1: Android platform names do not match
    if "android" in platform:
        platform = platform.replace("7-0", "7.0")
        retVal = f"test-{platform}"
        if suite is None:
            suite = "junit"
        if "junit" not in suite:
            retVal += "-geckoview"

    # account for the suite being replaced
    retVal += "-*"

    # Hack #2: wpt tasks using tag=<X> have no attributes in test-settings
    wpt_tags = ["privatebrowsing", "canvas", "webgpu"]
    for wt in wpt_tags:
        if f"web-platform-tests-{wt}" in label:
            retVal += f"-{wt}"

    # Hack #3: xpcshell-msix is a separate suite, but still has test_variant listed
    # remove this on April 15, 2025 as that is 1 year after bug 1891629 landed
    if test_variant:
        if not (suite == "xpcshell-msix" and test_variant == "msix"):
            retVal += f"-{test_variant}"

    return retVal


@dataclass
class Task:
    """Contains information pertaining to a single task."""

    id: str
    label: Optional[str] = field(default=None)
    duration: Optional[int] = field(default=None)
    result: Optional[str] = field(default=None)
    state: Optional[str] = field(default=None)
    queue_id: Optional[str] = field(default=None)
    action: Optional[Dict] = field(default=None)
    parent: Optional[str] = field(default=None)
    classification: Optional[str] = field(default="not classified")
    classification_note: Optional[str] = field(default=None)
    tags: Dict = field(default_factory=dict)
    tier: Optional[int] = field(default=None)
    suite: Optional[str] = field(default=None)
    platform: Optional[str] = field(default=None)
    variant: Optional[Dict] = field(default=None)
    job_kind: Optional[str] = field(default=None)

    @staticmethod
    def create(index=None, root_url=PRODUCTION_TASKCLUSTER_ROOT_URL, **kwargs):
        """Factory method to create a new Task instance.

        One of ``index`` or ``id`` must be specified.

        Args:
            index (str): Taskcluster index path used to find the task id (optional).
            kwargs (dict): Arguments to forward to the :class:`~mozci.task.Task` constructor.

        Raises:
            :class:`~mozci.errors.TaskNotFound`: when the task identified by
                specified index or task id could not be found.
        """
        if index and "id" not in kwargs:
            try:
                kwargs["id"] = find_task_id(index, root_url=root_url)
            except requests.exceptions.HTTPError as e:
                label = kwargs.get("label", "unknown label")
                raise TaskNotFound(id=index, label=label) from e

        if kwargs.get("label", "").startswith("test-"):
            return TestTask(**kwargs)
        return Task(**kwargs)

    @property
    def is_backfill(self) -> bool:
        return self.tags.get("action", "") == "backfill-task"

    @property
    def is_retrigger(self) -> bool:
        return self.tags.get("action", "").startswith("retrigger-")

    @property
    def is_tests_grouped(self) -> bool:
        return self.tags.get("tests_grouped", "") == "1"

    @property
    def failed(self):
        """Tasks with result `deadline-exceeded` are not considered as
        failures because it is likely that a dependency did not run.
        """
        return self.result in ("busted", "failed", "exception")

    @property
    def artifacts(self):
        """List the artifacts that were uploaded by this task."""
        return [artifact["name"] for artifact in list_artifacts(self.id)]

    @property
    def configuration(self):
        return _get_configuration(
            self.platform,
            self.suite,
            self.label,
            get_test_variant(self.variant, self.label),
        )

    def get_artifact(self, path, root_url=PRODUCTION_TASKCLUSTER_ROOT_URL):
        """Downloads and returns the content of an artifact.

        Args:
            path (str): The path component of the artifact URL. This is usually
                        something like `public/file.txt`. The values listed by
                        the `Task.artifacts` property can be passed into this
                        function.

        Returns:
            Contents of the artifact.

        Raises:
            :class:`~mozci.errors.ArtifactNotFound`: When the requested
                artifact does not exist.
        """
        try:
            data = get_artifact(self.id, path, root_url=root_url)
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 404:
                raise ArtifactNotFound(path, self.id, self.label) from e
            raise

        return data

    def to_json(self):
        """A JSON compatible representation of this Task in dictionary form.

        Only values passed in to the constructor will be included.

        Returns:
            dict: A JSON-compatible representation of the task.
        """
        sig = signature(self.__init__)
        return {k: v for k, v in self.__dict__.items() if k in sig.parameters}

    def _ensure_context(self, action):
        # An empty context means the action is relevant to the task-group.
        if len(action["context"]) == 0:
            return True

        for potential_matcher in action["context"]:
            if all(
                self.tags.get(key) == value for key, value in potential_matcher.items()
            ):
                return True

        return False

    def _get_action(self, decision_task, action_name):
        actions = decision_task.get_artifact("public/actions.json")
        for action in actions["actions"]:
            if action["name"] == action_name and self._ensure_context(action):
                assert action["kind"] == "hook"
                return action

        return None

    def _trigger_action(self, action, payload):
        tc_firefox_ci_credentials = config.get("taskcluster_firefox_ci", {})
        client_id = tc_firefox_ci_credentials.get("client_id")
        access_token = tc_firefox_ci_credentials.get("access_token")
        assert client_id and access_token, (
            "Missing Taskcluster Firefox CI credentials in mozci config secret"
        )

        options = taskcluster.optionsFromEnvironment()
        options["rootUrl"] = PRODUCTION_TASKCLUSTER_ROOT_URL
        options["credentials"] = {
            "clientId": client_id,
            "accessToken": access_token,
        }
        hooks = taskcluster.Hooks(options)

        result = hooks.triggerHook(action["hookGroupId"], action["hookId"], payload)
        return result["status"]["taskId"]

    def retrigger(self, push, times=3):
        """This function implements ability to perform retriggers on tasks."""
        decision_task = push.decision_task
        retrigger_action = self._get_action(decision_task, "retrigger-multiple")
        if not retrigger_action:
            logger.info(
                "Not retriggering task '{}', task should not be retriggered".format(
                    self.label
                )
            )
            return None

        hook_payload = jsone.render(
            retrigger_action["hookPayload"],
            context={
                "taskId": None,
                "taskGroupId": decision_task.id,
                "input": {"requests": [{"tasks": [self.label], "times": times}]},
            },
        )

        logger.info("Retriggering task '{}'".format(self.tags.get("label", "")))
        return self._trigger_action(retrigger_action, hook_payload)

    def confirm(self, push):
        """This function implements ability to trigger failure confirmation on tasks"""
        decision_task = push.decision_task
        retrigger_action = self._get_action(decision_task, "confirm-failures")
        if not retrigger_action:
            logger.info(
                "Not confirming task '{}', task should not be confirmed".format(
                    self.label
                )
            )
            return None

        hook_payload = jsone.render(
            retrigger_action["hookPayload"],
            context={
                "taskId": self.id,
                "taskGroupId": decision_task.id,
                "input": {},
            },
        )

        logger.info(
            "confirming failures for task '{}'".format(self.tags.get("label", ""))
        )
        return self._trigger_action(retrigger_action, hook_payload)

    def backfill(self, push, times: int | None = None):
        """
        This function implements ability to perform backfills on tasks.
        The number of task per push for the backfill can be set with the `times` argument.
        If unset, it is automatically detected depending on the classification of the task.
        """
        decision_task = push.decision_task
        backfill_action = self._get_action(decision_task, "backfill")

        if times is None:
            if (
                self.classification in ("not classified", "new failure not classified")
                or self.classification in INTERMITTENT_CLASSES
            ):
                times = 5
            else:
                times = 1
        hook_payload = jsone.render(
            backfill_action["hookPayload"],
            context={
                "taskId": self.id,
                "taskGroupId": decision_task.id,
                "input": {
                    "times": times
                    if self.classification
                    in ("not classified", "new failure not classified")
                    or self.classification in INTERMITTENT_CLASSES
                    else 1
                },
            },
        )

        logger.info("Backfilling task '{}'".format(self.tags.get("label", "")))
        return self._trigger_action(backfill_action, hook_payload)

    def is_build_failure(self) -> bool:
        """Returns whether a build task has failed."""
        return self.job_kind == "build" and self.failed

    def is_test_failure(self) -> bool:
        return self.job_kind == "test" and self.failed

    def can_retrigger_test_failure(self) -> bool:
        """Only allow to retrigger test tasks in failure state, with tier 1 or 2.
        Already classified tasks should not be retrigerred.
        If there is no task artifact which contains errorsummary in its name or is incomplete, then the task
        either hit an infrastructure issue or the test suite does not support failure summaries by errorsummary.
        """
        # TODO Use Task.is_confirmed_failure when it is ready
        if not self.is_test_failure():
            return False
        if self.tier not in (1, 2):
            return False
        if self.classification != "not classified":
            return False
        # Ignore backfills and retriggers, as only the initial failure should be triggered to avoid a loop.
        if self.is_backfill:
            return False
        if self.is_retrigger:
            return False
        if not any("errorsummary" in artifact for artifact in self.artifacts):
            logger.warning(
                f"Skipping retriggers for task {self.label}: task has no errorsummary artifact."
            )
            return False
        return True


@dataclass
class GroupResult:
    """Contains information relating to a single group failure within a TestTask."""

    group: str
    ok: bool
    # TODO: 'Optional' can be removed once https://github.com/mozilla/mozci/issues/662 is fixed.
    duration: Optional[int]


class FailureType(Enum):
    TIMEOUT = "timeout"
    CRASH = "crash"
    GENERIC = "generic"


TestName = NewType("TestName", str)
GroupName = NewType("GroupName", str)


@dataclass
class TestTask(Task):
    """Subclass containing additional information only relevant to 'test' tasks."""

    _results: Optional[List[GroupResult]] = field(default=None)
    _errors: Optional[List] = field(default=None)
    _failure_types: Optional[Dict[GroupName, List[Tuple[TestName, FailureType]]]] = (
        field(default=None)
    )

    @property
    def is_wpt(self):
        return any(
            s in self.label
            for s in {"web-platform-tests", "test-verify-wpt", "test-coverage-wpt"}
        )

    def retrieve_results(self, push):
        global slash_group_warned

        if is_no_groups_suite(self.label):
            assert self._errors is None, (
                f"{self.id} : {self.label} should have no errors"
            )
            self._errors = []

            assert self._results is None, (
                f"{self.id} : {self.label} should have no results"
            )
            self._results = []

            return

        if self.state == "completed":
            self._results = [
                GroupResult(group, result, duration)
                for group, (result, duration) in data.handler.get(
                    "test_task_groups", branch=push.branch, rev=push.rev, task=self
                ).items()
            ]
        else:
            self._results = []

        # Apply WPT workaround, needed at least until bug 1632546 is fixed.
        if self.is_wpt:
            # TODO: It can be removed a year after https://bugzilla.mozilla.org/show_bug.cgi?id=1688043 is fixed.
            # Filter out "/" groups.
            if not slash_group_warned and any(
                result.group == "/" for result in self._results
            ):
                slash_group_warned = True
                logger.warning(f"'/' group name in task {self.id}")

            self._results = [result for result in self._results if result.group != "/"]

            for result in self._results:
                result.group = wpt_workaround(result.group)

        # Filter out groups with bad names.
        # TODO: Figure out why we still have some groups with bad names.
        self._results = [
            result
            for result in self._results
            if not is_bad_group(self.id, result.group)
        ]

    @property
    def groups(self):
        return [result.group for result in self.results]

    @property
    def results(self):
        assert self._results is not None
        return self._results

    @property
    def errors(self):
        if self._errors is None:
            self._errors = data.handler.get("test_task_errors", task=self)
        return self._errors

    @property
    def failure_types(self):
        """
        Returns a dict mapping each failing group on this TestTask
        to a list of its failing test names and their FailureType.

        e.g:
        {"group/failing/on-this-task.ini": [
            ("group/failing/test-file-1.js", "timeout"),
            ("group/failing/test-file-2.js", "crash"),
        ]}
        """
        if self._failure_types is None:
            self._failure_types = data.handler.get(
                "test_task_failure_types", task_id=self.id
            )
        return self._failure_types


# Don't perform type checking because of https://github.com/python/mypy/issues/5374.
@dataclass  # type: ignore
class RunnableSummary(ABC):
    @property
    def is_intermittent(self):
        return self.status == Status.INTERMITTENT or any(
            c in INTERMITTENT_CLASSES for c, n in self.classifications
        )

    @property
    @abstractmethod
    def classifications(self): ...

    @property
    @abstractmethod
    def status(self): ...

    @property
    @abstractmethod
    def durations(self): ...

    @property
    @abstractmethod
    def total_duration(self): ...

    @property
    @abstractmethod
    def median_duration(self): ...


@dataclass
class GroupSummary(RunnableSummary):
    """Summarizes the overall state of a group (across retriggers)."""

    name: str
    tasks: List[TestTask]

    def __post_init__(self):
        # WPT names are not normalized relative to topsrcdir, so subsequent check
        # will fail unless normalized.
        if self.name.startswith("/"):
            self.name = wpt_workaround(self.name)
        assert all(self.name in t.groups for t in self.tasks)

    @property
    def classifications(self):
        return [
            (t.classification, t.classification_note)
            for t in self.tasks
            if t.failed
            and any(result.group == self.name and not result.ok for result in t.results)
        ]

    # TODO: Only used for testing, find a way to monkey patch!
    @classifications.setter
    def classifications(self, classifications):
        for c, t in zip(classifications, self.tasks):
            t.classification = c

    @property
    def durations(self) -> List[int]:
        data = []
        for task in self.tasks:
            for result in task.results:
                if result.group == self.name:
                    data.append(result.duration)
        return data

    @property
    def total_duration(self):
        return sum(self.durations)

    @property
    def median_duration(self):
        return median(self.durations)

    @memoized_property
    def status(self):
        overall_status_by_label = {}
        for task in self.tasks:
            for result in task.results:
                if result.group != self.name:
                    continue

                if not result.ok:
                    status = Status.FAIL
                else:
                    status = Status.PASS

                if task.label not in overall_status_by_label:
                    overall_status_by_label[task.label] = status
                elif status != overall_status_by_label[task.label]:
                    overall_status_by_label[task.label] = Status.INTERMITTENT

        # If the manifest failed intermittently at least in one task, we
        # consider it to be intermittent.
        if any(
            status == Status.INTERMITTENT for status in overall_status_by_label.values()
        ):
            return Status.INTERMITTENT

        # Otherwise, if the manifest failed at least once in any of the tasks,
        # we consider it as a failure.
        if any(status == Status.FAIL for status in overall_status_by_label.values()):
            return Status.FAIL

        # Otherwise, the manifest passed in all tasks, so we consider it a pass.
        return Status.PASS

    @memoized_property
    def failing_tasks(self):
        # List all tasks with some test results failing for that group
        return [
            task
            for task in self.tasks
            if any(
                not result.ok and result.group == self.name for result in task.results
            )
        ]

    def is_confirmed_failure(self) -> Optional[bool]:
        # match labels to -cf labels, and if ran, return True || False
        # if not run -cf task, return None
        # return False = intermittent
        # return True = regression
        cf_to_results = []

        for task in self.tasks:
            for result in task.results:
                if (
                    task.label
                    and task.label.endswith("-cf")
                    and result.group == self.name
                ):
                    cf_to_results.append(result.ok)

        if not cf_to_results:
            return None

        if False in cf_to_results:
            return True
        return False

    def is_config_consistent_failure(self, minimum_count: int = 3) -> Optional[bool]:
        config_to_results = collections.defaultdict(list)
        for task in self.tasks:
            for result in task.results:
                if result.group == self.name:
                    config_to_results[task.configuration].append(result.ok)

        # If there is no config for which we have at least 'minimum_count' runs, return None (that is, unknown).
        if all(len(results) < minimum_count for results in config_to_results.values()):
            return None

        # Return True if there is at least one configuration for which we have only failures, False otherwise.
        return any(
            len(results) >= minimum_count and not any(results)
            for results in config_to_results.values()
        )

    def is_cross_config_failure(self, minimum_count: int = 2) -> Optional[bool]:
        states = [
            result.ok
            for task in self.tasks
            for result in task.results
            if result.group == self.name
        ]

        nb = len(states)
        nb_passed = sum(states)  # Number of True booleans in the states list
        nb_failed = nb - nb_passed

        # If the group run on fewer than 'minimum_count' tasks, we don't have enough information to tell.
        if nb < minimum_count:
            return None

        # A group is a cross config failure when it is failing in all tasks and not
        # only in some.
        return nb_failed > 0 and nb_passed == 0

    # A group that is either failing across configurations or multiple times on the same configuration.
    def is_consistent_failure(
        self,
        cross_config_count: Optional[int] = 2,
        config_consistent_count: Optional[int] = 3,
    ) -> Optional[bool]:
        assert cross_config_count is not None or config_consistent_count is not None

        is_cross_config = None
        if cross_config_count is not None:
            is_cross_config = self.is_cross_config_failure(cross_config_count)
            if is_cross_config:
                return True

        is_config_consistent = None
        if config_consistent_count is not None:
            is_config_consistent = self.is_config_consistent_failure(
                config_consistent_count
            )
            if is_config_consistent:
                return True

        # We don't have enough information to tell if this is cross-config or consistently failing on the
        # same configuration (e.g. if we only run the test once on a single configuration, we can't tell
        # if it's cross-config or not and we can't tell if a retrigger would have failed).
        if is_cross_config is None and is_config_consistent is None:
            return None

        # If we got here, it means the group is:
        # - either failing on some configuration and passing on others;
        # - or failing on a run on a given configuration and passing on another run on the same configuration.
        return False


@dataclass
class LabelSummary(RunnableSummary):
    """Summarizes the overall state of a task label (across retriggers)."""

    label: str
    tasks: List[Task]

    def __post_init__(self):
        assert all(t.label == self.label for t in self.tasks)

    @property
    def classifications(self):
        return [
            (t.classification, t.classification_note) for t in self.tasks if t.failed
        ]

    @property
    def durations(self):
        return [t.duration for t in self.tasks]

    @property
    def total_duration(self):
        return sum(self.durations)

    @property
    def median_duration(self):
        return median(self.durations)

    @memoized_property
    def status(self):
        overall_status = None
        for task in self.tasks:
            if task.failed:
                status = Status.FAIL
            else:
                status = Status.PASS

            if overall_status is None:
                overall_status = status
            elif status != overall_status:
                overall_status = Status.INTERMITTENT

        return overall_status
