from collections.abc import Iterator
from time import sleep
from typing import Any

from moto.core.base_backend import BackendDict, BaseBackend
from moto.core.resource_tagging import TaggableResourcesMixin, TaggedResource
from moto.utilities.tagging_service import TaggingService

from ..exceptions import (
    SWFDomainAlreadyExistsFault,
    SWFDomainDeprecatedFault,
    SWFTypeAlreadyExistsFault,
    SWFTypeDeprecatedFault,
    SWFUnknownResourceFault,
    SWFValidationException,
)
from .activity_task import ActivityTask  # noqa
from .activity_type import ActivityType  # noqa
from .decision_task import DecisionTask  # noqa
from .domain import Domain  # noqa
from .generic_type import GenericType, TGenericType  # noqa
from .history_event import HistoryEvent  # noqa
from .timeout import Timeout  # noqa
from .timer import Timer  # noqa
from .workflow_execution import WorkflowExecution  # noqa
from .workflow_type import WorkflowType  # noqa

KNOWN_SWF_TYPES = {"activity": ActivityType, "workflow": WorkflowType}


class SWFBackend(BaseBackend, TaggableResourcesMixin):
    SERVICE_NAMESPACE = "swf"

    def __init__(self, region_name: str, account_id: str):
        super().__init__(region_name, account_id)
        self.domains: list[Domain] = []
        self.tagger = TaggingService()

    def _get_domain(self, name: str, ignore_empty: bool = False) -> Domain:
        matching = [domain for domain in self.domains if domain.name == name]
        if not matching and not ignore_empty:
            raise SWFUnknownResourceFault("domain", name)
        if matching:
            return matching[0]
        return None  # type: ignore

    def _process_timeouts(self) -> None:
        for domain in self.domains:
            for wfe in domain.workflow_executions:
                wfe._process_timeouts()

    def list_domains(
        self, status: str, reverse_order: bool | None = None
    ) -> list[Domain]:
        domains = [domain for domain in self.domains if domain.status == status]
        domains = sorted(domains, key=lambda domain: domain.name)
        if reverse_order:
            domains = reversed(domains)  # type: ignore[assignment]
        return domains

    def list_open_workflow_executions(
        self,
        domain_name: str,
        maximum_page_size: int,
        tag_filter: dict[str, str],
        reverse_order: bool,
    ) -> list[WorkflowExecution]:
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        if domain.status == "DEPRECATED":
            raise SWFDomainDeprecatedFault(domain_name)
        open_wfes = [
            wfe for wfe in domain.workflow_executions if wfe.execution_status == "OPEN"
        ]

        if tag_filter:
            for open_wfe in open_wfes:
                if tag_filter["tag"] not in open_wfe.tag_list:
                    open_wfes.remove(open_wfe)
        if reverse_order:
            open_wfes = reversed(open_wfes)  # type: ignore[assignment]
        return open_wfes[0:maximum_page_size]

    def list_closed_workflow_executions(
        self,
        domain_name: str,
        tag_filter: dict[str, str],
        close_status_filter: dict[str, str],
        maximum_page_size: int,
        reverse_order: bool,
    ) -> list[WorkflowExecution]:
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        if domain.status == "DEPRECATED":
            raise SWFDomainDeprecatedFault(domain_name)
        closed_wfes = [
            wfe
            for wfe in domain.workflow_executions
            if wfe.execution_status == "CLOSED"
        ]
        if tag_filter:
            for closed_wfe in closed_wfes:
                if tag_filter["tag"] not in closed_wfe.tag_list:
                    closed_wfes.remove(closed_wfe)
        if close_status_filter:
            for closed_wfe in closed_wfes:
                if close_status_filter != closed_wfe.close_status:  # type: ignore
                    closed_wfes.remove(closed_wfe)
        if reverse_order:
            closed_wfes = reversed(closed_wfes)  # type: ignore[assignment]
        return closed_wfes[0:maximum_page_size]

    def register_domain(
        self,
        name: str,
        workflow_execution_retention_period_in_days: int,
        description: str | None = None,
        tags: list[dict[str, str]] | None = None,
    ) -> None:
        if self._get_domain(name, ignore_empty=True):
            raise SWFDomainAlreadyExistsFault(name)
        domain = Domain(
            name,
            workflow_execution_retention_period_in_days,
            account_id=self.account_id,
            region_name=self.region_name,
            description=description,
        )
        self.domains.append(domain)
        if tags:
            self.tagger.tag_resource(domain.to_short_dict()["arn"], tags)

    def deprecate_domain(self, name: str) -> None:
        domain = self._get_domain(name)
        if domain.status == "DEPRECATED":
            raise SWFDomainDeprecatedFault(name)
        domain.status = "DEPRECATED"

    def undeprecate_domain(self, name: str) -> None:
        domain = self._get_domain(name)
        if domain.status == "REGISTERED":
            raise SWFDomainAlreadyExistsFault(name)
        domain.status = "REGISTERED"

    def describe_domain(self, name: str) -> Domain | None:
        return self._get_domain(name)

    def list_types(
        self,
        kind: str,
        domain_name: str,
        status: str,
        reverse_order: bool | None = None,
    ) -> list[GenericType]:
        domain = self._get_domain(domain_name)
        _types: list[GenericType] = domain.find_types(kind, status)
        _types = sorted(_types, key=lambda domain: domain.name)
        if reverse_order:
            _types = reversed(_types)  # type: ignore
        return _types

    def register_type(
        self, kind: str, domain_name: str, name: str, version: str, **kwargs: Any
    ) -> None:
        domain = self._get_domain(domain_name)
        _type: GenericType = domain.get_type(kind, name, version, ignore_empty=True)
        if _type:
            raise SWFTypeAlreadyExistsFault(_type)
        _class = KNOWN_SWF_TYPES[kind]
        _type = _class(name, version, **kwargs)
        domain.add_type(_type)

    def deprecate_type(
        self, kind: str, domain_name: str, name: str, version: str
    ) -> None:
        domain = self._get_domain(domain_name)
        _type: GenericType = domain.get_type(kind, name, version)
        if _type.status == "DEPRECATED":
            raise SWFTypeDeprecatedFault(_type)
        _type.status = "DEPRECATED"

    def undeprecate_type(
        self, kind: str, domain_name: str, name: str, version: str
    ) -> None:
        domain = self._get_domain(domain_name)
        _type: GenericType = domain.get_type(kind, name, version)
        if _type.status == "REGISTERED":
            raise SWFTypeAlreadyExistsFault(_type)
        _type.status = "REGISTERED"

    def describe_type(
        self, kind: str, domain_name: str, name: str, version: str
    ) -> GenericType:
        domain = self._get_domain(domain_name)
        return domain.get_type(kind, name, version)

    def start_workflow_execution(
        self,
        domain_name: str,
        workflow_id: str,
        workflow_name: str,
        workflow_version: str,
        tag_list: dict[str, str] | None = None,
        workflow_input: str | None = None,
        **kwargs: Any,
    ) -> WorkflowExecution:
        domain = self._get_domain(domain_name)
        wf_type: WorkflowType = domain.get_type(
            "workflow", workflow_name, workflow_version
        )  # type: ignore
        if wf_type.status == "DEPRECATED":
            raise SWFTypeDeprecatedFault(wf_type)
        wfe = WorkflowExecution(
            domain,
            wf_type,
            workflow_id,
            tag_list=tag_list,
            workflow_input=workflow_input,
            **kwargs,
        )
        domain.add_workflow_execution(wfe)
        wfe.start()

        return wfe

    def describe_workflow_execution(
        self, domain_name: str, run_id: str, workflow_id: str
    ) -> WorkflowExecution | None:
        # process timeouts on all objects
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        return domain.get_workflow_execution(workflow_id, run_id=run_id)

    def poll_for_decision_task(
        self, domain_name: str, task_list: list[str], identity: str | None = None
    ) -> DecisionTask | None:
        # process timeouts on all objects
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        # Real SWF cases:
        # - case 1: there's a decision task to return, return it
        # - case 2: there's no decision task to return, so wait for timeout
        #           and if a new decision is schedule, start and return it
        # - case 3: timeout reached, no decision, return an empty decision
        #           (e.g. a decision with an empty "taskToken")
        #
        # For the sake of simplicity, we forget case 2 for now, so either
        # there's a DecisionTask to return, either we return a blank one.
        #
        # SWF client libraries should cope with that easily as long as tests
        # aren't distributed.
        #
        # TODO: handle long polling (case 2) for decision tasks
        candidates = []

        # Collect candidate scheduled tasks from open workflow executions
        # matching the selected task list.
        #
        # If another decision task is already started, then no candidates
        # will be produced for that workflow execution. This is because only one
        # decision task can be started at any given time.
        # See https://docs.aws.amazon.com/amazonswf/latest/developerguide/swf-dev-tasks.html
        for wfe in domain.workflow_executions:
            if wfe.task_list == task_list and wfe.open:
                wfe_candidates = []
                found_started = False
                for task in wfe.decision_tasks:
                    if task.state == "STARTED":
                        found_started = True
                        break
                    elif task.state == "SCHEDULED":
                        wfe_candidates.append(task)
                if not found_started:
                    candidates += wfe_candidates

        if any(candidates):
            # TODO: handle task priorities (but not supported by boto for now)
            task = min(candidates, key=lambda d: d.scheduled_at)
            wfe = task.workflow_execution
            wfe.start_decision_task(task.task_token, identity=identity)
            return task
        else:
            # Sleeping here will prevent clients that rely on the timeout from
            # entering in a busy waiting loop.
            sleep(1)
            return None

    def count_pending_decision_tasks(
        self, domain_name: str, task_list: list[str]
    ) -> int:
        # process timeouts on all objects
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        count = 0
        for wfe in domain.workflow_executions:
            if wfe.task_list == task_list:
                count += wfe.open_counts["openDecisionTasks"]
        return count

    def respond_decision_task_completed(
        self,
        task_token: str,
        decisions: list[dict[str, Any]] | None = None,
        execution_context: str | None = None,
    ) -> None:
        # process timeouts on all objects
        self._process_timeouts()
        # let's find decision task
        decision_task = None
        for domain in self.domains:
            for wfe in domain.workflow_executions:
                for dt in wfe.decision_tasks:
                    if dt.task_token == task_token:
                        decision_task = dt
        # no decision task found
        if not decision_task:
            # In the real world, SWF distinguishes an obviously invalid token and a
            # token that has no corresponding decision task. For the latter it seems
            # to wait until a task with that token comes up (which looks like a smart
            # choice in an eventually-consistent system). The call doesn't seem to
            # timeout shortly, it takes 3 or 4 minutes to result in:
            #    BotoServerError: 500 Internal Server Error
            #    {"__type":"com.amazon.coral.service#InternalFailure"}
            # This behavior is not documented clearly in SWF docs and we'll ignore it
            # in moto, as there is no obvious reason to rely on it in tests.
            raise SWFValidationException("Invalid token")
        # decision task found, but WorflowExecution is CLOSED
        wfe = decision_task.workflow_execution
        if not wfe.open:
            raise SWFUnknownResourceFault(
                "execution",
                f"WorkflowExecution=[workflowId={wfe.workflow_id}, runId={wfe.run_id}]",
            )
        # decision task found, but already completed
        if decision_task.state != "STARTED":
            if decision_task.state == "COMPLETED":
                raise SWFUnknownResourceFault(
                    f"decision task, scheduledEventId = {decision_task.scheduled_event_id}"
                )
            else:
                raise ValueError(
                    "This shouldn't happen: you have to PollForDecisionTask to get a token, "
                    "which changes DecisionTask status to 'STARTED' ; then it can only change "
                    "to 'COMPLETED'. If you didn't hack moto/swf internals, this is probably "
                    "a bug in moto, please report it, thanks!"
                )
        # everything's good
        if decision_task:
            wfe = decision_task.workflow_execution
            wfe.complete_decision_task(
                decision_task.task_token,
                decisions=decisions,
                execution_context=execution_context,
            )

    def poll_for_activity_task(
        self, domain_name: str, task_list: list[str], identity: str | None = None
    ) -> ActivityTask | None:
        # process timeouts on all objects
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        # Real SWF cases:
        # - case 1: there's an activity task to return, return it
        # - case 2: there's no activity task to return, so wait for timeout
        #           and if a new activity is scheduled, return it
        # - case 3: timeout reached, no activity task, return an empty response
        #           (e.g. a response with an empty "taskToken")
        #
        # For the sake of simplicity, we forget case 2 for now, so either
        # there's an ActivityTask to return, either we return a blank one.
        #
        # SWF client libraries should cope with that easily as long as tests
        # aren't distributed.
        #
        # TODO: handle long polling (case 2) for activity tasks
        candidates = []
        for _task_list, tasks in domain.activity_task_lists.items():
            if _task_list == task_list:
                candidates += [t for t in tasks if t.state == "SCHEDULED"]
        if any(candidates):
            # TODO: handle task priorities (but not supported by boto for now)
            task = min(candidates, key=lambda d: d.scheduled_at)
            wfe = task.workflow_execution
            wfe.start_activity_task(task.task_token, identity=identity)
            return task
        else:
            # Sleeping here will prevent clients that rely on the timeout from
            # entering in a busy waiting loop.
            sleep(1)
            return None

    def count_pending_activity_tasks(
        self, domain_name: str, task_list: list[str]
    ) -> int:
        # process timeouts on all objects
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        count = 0
        for _task_list, tasks in domain.activity_task_lists.items():
            if _task_list == task_list:
                pending = [t for t in tasks if t.state in ["SCHEDULED", "STARTED"]]
                count += len(pending)
        return count

    def _find_activity_task_from_token(self, task_token: str) -> ActivityTask:
        activity_task = None
        for domain in self.domains:
            for wfe in domain.workflow_executions:
                for task in wfe.activity_tasks:
                    if task.task_token == task_token:
                        activity_task = task
        # no task found
        if not activity_task:
            # Same as for decision tasks, we raise an invalid token BOTH for clearly
            # wrong SWF tokens and OK tokens but not used correctly. This should not
            # be a problem in moto.
            raise SWFValidationException("Invalid token")
        # activity task found, but WorflowExecution is CLOSED
        wfe = activity_task.workflow_execution
        if not wfe.open:
            raise SWFUnknownResourceFault(
                "execution",
                f"WorkflowExecution=[workflowId={wfe.workflow_id}, runId={wfe.run_id}]",
            )
        # activity task found, but already completed
        if activity_task.state != "STARTED":
            if activity_task.state == "COMPLETED":
                raise SWFUnknownResourceFault(
                    f"activity, scheduledEventId = {activity_task.scheduled_event_id}"
                )
            else:
                raise ValueError(
                    "This shouldn't happen: you have to PollForActivityTask to get a token, "
                    "which changes ActivityTask status to 'STARTED' ; then it can only change "
                    "to 'COMPLETED'. If you didn't hack moto/swf internals, this is probably "
                    "a bug in moto, please report it, thanks!"
                )
        # everything's good
        return activity_task

    def respond_activity_task_completed(
        self, task_token: str, result: Any = None
    ) -> None:
        # process timeouts on all objects
        self._process_timeouts()
        activity_task = self._find_activity_task_from_token(task_token)
        wfe = activity_task.workflow_execution
        wfe.complete_activity_task(activity_task.task_token, result=result)

    def respond_activity_task_failed(
        self, task_token: str, reason: str | None = None, details: Any = None
    ) -> None:
        # process timeouts on all objects
        self._process_timeouts()
        activity_task = self._find_activity_task_from_token(task_token)
        wfe = activity_task.workflow_execution
        wfe.fail_activity_task(activity_task.task_token, reason=reason, details=details)

    def terminate_workflow_execution(
        self,
        domain_name: str,
        workflow_id: str,
        child_policy: Any = None,
        details: Any = None,
        reason: str | None = None,
        run_id: str | None = None,
    ) -> None:
        # process timeouts on all objects
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        wfe = domain.get_workflow_execution(
            workflow_id, run_id=run_id, raise_if_closed=True
        )
        wfe.terminate(child_policy=child_policy, details=details, reason=reason)  # type: ignore[union-attr]

    def record_activity_task_heartbeat(
        self, task_token: str, details: Any = None
    ) -> None:
        # process timeouts on all objects
        self._process_timeouts()
        activity_task = self._find_activity_task_from_token(task_token)
        activity_task.reset_heartbeat_clock()
        if details:
            activity_task.details = details

    def signal_workflow_execution(
        self,
        domain_name: str,
        signal_name: str,
        workflow_id: str,
        workflow_input: Any = None,
        run_id: str | None = None,
    ) -> None:
        # process timeouts on all objects
        self._process_timeouts()
        domain = self._get_domain(domain_name)
        wfe = domain.get_workflow_execution(
            workflow_id, run_id=run_id, raise_if_closed=True
        )
        wfe.signal(signal_name, workflow_input)  # type: ignore[union-attr]

    # Resource Groups Tagging API (TaggableResourcesMixin method overrides)
    def iter_tagged_resources(self) -> Iterator[TaggedResource]:
        for domain in self.domains:
            domain_arn = domain.to_short_dict()["arn"]
            yield TaggedResource(
                arn=domain_arn,
                tags=self.tagger.get_tag_dict_for_resource(domain_arn),
                resource_type="swf:domain",
            )

    def tag_resource(self, arn: str, tags: dict[str, str]) -> None:
        self.tagger.tag_resource(arn, [{"Key": k, "Value": v} for k, v in tags.items()])

    def untag_resource(self, arn: str, tag_keys: list[str]) -> None:
        self.tagger.untag_resource_using_names(arn, tag_keys)


swf_backends = BackendDict(SWFBackend, "swf")
