# This file is part of Buildbot.  Buildbot is free software: you can
# redistribute it and/or modify it under the terms of the GNU General Public
# License as published by the Free Software Foundation, version 2.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# Copyright Buildbot Team Members

from __future__ import annotations

import enum
from typing import TYPE_CHECKING

from twisted.internet import defer
from twisted.python import log

if TYPE_CHECKING:
    from buildbot.process.builder import Builder
    from buildbot.worker.base import AbstractWorker
    from buildbot.worker.latent import AbstractLatentWorker


class States(enum.Enum):
    # The worker isn't attached, or is in the process of attaching.
    DETACHED = 0
    # The worker is available to build: either attached, or a latent worker.
    AVAILABLE = 1
    # The worker is building.
    BUILDING = 2


class AbstractWorkerForBuilder:
    def __init__(self, builder: Builder):
        self.ping_watchers: list[defer.Deferred] = []
        self.state: States | None = None  # set in subclass
        self.worker: AbstractWorker | None = None
        self.builder = builder
        self.builder_name = builder.name
        self.locks = None

    def __repr__(self):
        r = ["<", self.__class__.__name__]
        if self.builder_name:
            r.extend([" builder=", repr(self.builder_name)])
        if self.worker:
            r.extend([" worker=", repr(self.worker.workername)])
        r.extend([" state=", self.state.name, ">"])
        return ''.join(r)

    def getWorkerCommandVersion(self, command, oldversion=None):
        if self.remoteCommands is None:
            # the worker is 0.5.0 or earlier
            return oldversion
        return self.remoteCommands.get(command)

    def isAvailable(self):
        # if this WorkerForBuilder is busy, then it's definitely not available
        if self.isBusy():
            return False

        # otherwise, check in with the Worker
        if self.worker:
            return self.worker.canStartBuild()

        # no worker? not very available.
        return False

    def isBusy(self):
        return self.state != States.AVAILABLE

    def buildStarted(self):
        self.state = States.BUILDING
        self.worker.buildStarted(self)

    def buildFinished(self):
        self.state = States.AVAILABLE
        if self.worker:
            self.worker.buildFinished(self)

    @defer.inlineCallbacks
    def attached(self, worker: AbstractWorker, commands):
        """
        @type  worker: L{buildbot.worker.Worker}
        @param worker: the Worker that represents the worker as a whole
        @type  commands: dict: string -> string, or None
        @param commands: provides the worker's version of each RemoteCommand
        """
        self.remoteCommands = commands  # maps command name to version
        if self.worker is None:
            self.worker = worker
            self.worker.addWorkerForBuilder(self)
        else:
            assert self.worker == worker
        log.msg(f"Worker {worker.workername} attached to {self.builder_name}")

        yield self.worker.conn.remotePrint(message="attached")

    def substantiate_if_needed(self, build):
        return defer.succeed(True)

    def insubstantiate_if_needed(self):
        pass

    def ping(self):
        """Ping the worker to make sure it is still there. Returns a Deferred
        that fires with True if it is.
        """
        newping = not self.ping_watchers
        d = defer.Deferred()
        self.ping_watchers.append(d)
        if newping:
            Ping().ping(self.worker.conn).addBoth(self._pong)

        return d

    def abortPingIfAny(self):
        watchers = self.ping_watchers
        self.ping_watchers = []
        for d in watchers:
            d.errback(PingException('aborted ping'))

    def _pong(self, res):
        watchers = self.ping_watchers
        self.ping_watchers = []
        for d in watchers:
            d.callback(res)

    def detached(self):
        log.msg(f"Worker {self.worker.workername} detached from {self.builder_name}")
        if self.worker:
            self.worker.removeWorkerForBuilder(self)
        self.worker = None
        self.remoteCommands = None


class PingException(Exception):
    pass


class Ping:
    running = False

    def ping(self, conn):
        assert not self.running
        if not conn:
            # clearly the ping must fail
            return defer.fail(PingException("Worker not connected?"))
        self.running = True
        log.msg("sending ping")
        self.d = defer.Deferred()
        # TODO: add a distinct 'ping' command on the worker.. using 'print'
        # for this purpose is kind of silly.
        conn.remotePrint(message="ping").addCallbacks(
            self._pong, self._ping_failed, errbackArgs=(conn,)
        )
        return self.d

    def _pong(self, res):
        log.msg("ping finished: success")
        self.d.callback(True)

    def _ping_failed(self, res, conn):
        log.msg("ping finished: failure")
        # the worker has some sort of internal error, disconnect them. If we
        # don't, we'll requeue a build and ping them again right away,
        # creating a nasty loop.
        conn.loseConnection()
        self.d.errback(res)


class WorkerForBuilder(AbstractWorkerForBuilder):
    def __init__(self, builder: Builder):
        super().__init__(builder)
        self.state = States.DETACHED

    @defer.inlineCallbacks
    def attached(self, worker, commands):
        yield super().attached(worker, commands)

        # Only set available on non-latent workers, since latent workers
        # only attach while a build is in progress.
        self.state = States.AVAILABLE

    def detached(self):
        super().detached()
        if self.worker:
            self.worker.removeWorkerForBuilder(self)
        self.worker = None
        self.state = States.DETACHED


class LatentWorkerForBuilder(AbstractWorkerForBuilder):
    def __init__(self, worker: AbstractLatentWorker, builder: Builder):
        super().__init__(builder)
        self.worker: AbstractLatentWorker | None = worker
        self.state = States.AVAILABLE
        self.worker.addWorkerForBuilder(self)
        log.msg(f"Latent worker {worker.workername} attached to {self.builder_name}")

    def substantiate_if_needed(self, build):
        self.state = States.DETACHED
        d = self.substantiate(build)
        return d

    def insubstantiate_if_needed(self):
        if self.worker:
            self.worker.insubstantiate()

    def attached(self, worker, commands):
        # When a latent worker is attached, it is actually because it prepared for a build
        # thus building and not available like for normal worker
        if self.state == States.DETACHED:
            self.state = States.BUILDING
        return super().attached(worker, commands)

    def substantiate(self, build):
        if self.worker:
            return self.worker.substantiate(self, build)
        return defer.succeed(False)

    def ping(self):
        if not self.worker.substantiated:
            return defer.fail(PingException("worker is not substantiated"))
        return super().ping()
