<?php

namespace Fhp;

use Fhp\Model\NoPsd2TanMode;
use Fhp\Model\TanMedium;
use Fhp\Model\TanMode;
use Fhp\Model\VopConfirmationRequest;
use Fhp\Model\VopConfirmationRequestImpl;
use Fhp\Model\VopPollingInfo;
use Fhp\Model\VopVerificationResult;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Fhp\Options\SanitizingLogger;
use Fhp\Protocol\BPD;
use Fhp\Protocol\DialogInitialization;
use Fhp\Protocol\GetTanMedia;
use Fhp\Protocol\Message;
use Fhp\Protocol\MessageBuilder;
use Fhp\Protocol\ServerException;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\HIBPA\HIBPAv3;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\Segment\HKEND\HKENDv1;
use Fhp\Segment\HKIDN\HKIDNv2;
use Fhp\Segment\HKVVB\HKVVBv3;
use Fhp\Segment\TAN\HITAN;
use Fhp\Segment\TAN\HKTAN;
use Fhp\Segment\TAN\HKTANFactory;
use Fhp\Segment\TAN\HKTANv6;
use Fhp\Segment\VPP\HKVPPv1;
use Fhp\Segment\VPP\VopHelper;
use Fhp\Syntax\InvalidResponseException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
 * This is the main class of this library. Please see the Samples directory for how to use it.
 * This class is not thread-safe. Do not call its funtions concurrently.
 */
class FinTs
{
    // Things we retrieved from the user / the calling application.
    /** @var FinTsOptions */
    private $options;
    /** @var Credentials|null */
    private $credentials;
    /** @var SanitizingLogger */
    private $logger;

    // The TAN mode and medium to be used for business transactions that require a TAN.
    /** @var TanMode|int|null */
    private $selectedTanMode;
    /** @var string|null This is a {@link TanMedium::getName()}, but we don't have the {@link TanMedium} instance. */
    private $selectedTanMedium;

    // State that persists across physical connections, dialogs and even PHP processes.
    /** @var BPD|null */
    private $bpd;
    /** @var int[]|null The IDs of the {@link TanMode}s from the BPD which the user is allowed to use. */
    private $allowedTanModes;
    /** @var UPD|null */
    private $upd;

    // State of the current connection/dialog with the bank.
    /** @var Connection|null */
    private $connection;
    /** @var string|null */
    private $kundensystemId;
    /** @var string|null */
    protected $dialogId;
    /** @var int */
    private $messageNumber = 1;

    /**
     * Use this factory to create new instances.
     * @param FinTsOptions $options Configuration options for the connection to the bank.
     * @param Credentials $credentials Authentication information for the user. Note: This library does not support
     *     anonymous connections, so the credentials are mandatory.
     * @param string|null $persistedInstance The return value of {@link persist()} of a previous FinTs instance,
     *     usually from an earlier PHP process. NOTE: Each persisted instance may be used only once and should be
     *     considered invalid afterwards. To continue the session, call {@link persist()} again.
     */
    public static function new(FinTsOptions $options, Credentials $credentials, ?string $persistedInstance = null): FinTs
    {
        $options->validate();
        $fints = new static($options, $credentials);

        if ($persistedInstance !== null) {
            $fints->loadPersistedInstance($persistedInstance);
        }
        return $fints;
    }

    /**
     * This function allows fetching the BPD without knowing the user's credentials yet, by using an anonymous dialog.
     * Note: If this fails with an error saying that your bank does not support the anonymous dialog, you probably need
     * to use {@link NoPsd2TanMode} for regular login.
     * @param FinTsOptions $options Configuration options for the connection to the bank.
     * @param ?LoggerInterface $logger An optional logger to record messages exchanged with the bank.
     * @return BPD Bank parameters that tell the client software what features the bank supports.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly.
     * @throws ServerException When the server resopnds with an error.
     */
    public static function fetchBpd(FinTsOptions $options, ?LoggerInterface $logger = null): BPD
    {
        $options->validate();
        $fints = new static($options, null);
        if ($logger !== null) {
            $fints->setLogger($logger);
        }
        return $fints->getBpd();
    }

    /** Please use the factory above. */
    protected function __construct(FinTsOptions $options, ?Credentials $credentials)
    {
        $this->options = $options;
        $this->credentials = $credentials;
        $this->setLogger(new NullLogger());
    }

    /**
     * Destructing the object only disconnects. Please use {@link close()} if you want to properly "log out", i.e., end
     * the FinTs dialog.
     */
    public function __destruct()
    {
        $this->disconnect();
    }

    /**
     * Returns a serialized form this object. This is different from PHP's {@link \Serializable} in that it only
     * serializes parts and cannot simply be restored with {@link unserialize()}, because the {@link FinTsOptions} and
     * the {@link Credentials} need to be passed to {@link FinTs::new()}, in addition to the string returned here.
     *
     * Alternatively, you can use {@link loadPersistedInstance()} to separate constructing the instance and resuming it.
     *
     * There are broadly two reasons to persist the instance:
     * 1. During login or certain other actions, you may encounter a TAN/2FA request ({@link BaseAction::needsTan()}
     *    returns true). In that case, you MUST call {@link submitTan()} or {@link checkDecoupledSubmission()} later,
     *    without losing the dialog state in between. Depending on your application's circumstances, one option might be
     *    to simply keep the {@link FinTs} instance itself alive in memory (e.g., in a CLI application, you can block
     *    until the user provides the TAN). In most server-based scenarios, however, the PHP process will shut down and
     *    a new PHP process will be started later, when the client calls again to provide the TAN. In this case, you
     *    need to persist the {@link FinTs} instance and restore it later in order for the action to succeed.
     * 2. Even when there is no outstanding action and after logging out with {@link close()}, it's beneficial to
     *    persist the instance (with $minimal=false). By reusing the cached BPD, UPD and TAN mode information upon the
     *    next {@link login()}, a few roundtrips to the FinTS server can be avoided.
     *
     * IMPORTANT: Each serialized instance (each value returned from {@link persist()}) can only be used once. After
     * passing it to {@link FinTs::new()} or {@link loadPersistedInstance()}, you must consider it invalidated. To keep
     * the same instance/session alive, you must call {@link persist()} again.
     *
     * @param bool $minimal If true, the return value only contains the values necessary to complete an outstanding TAN
     *     request, but not the relatively large BPD/UPD, which can always be retrieved again later with a few extra
     *     requests to the server. So the persisting doesn't work for use case (2.) from above, but in turn, it saves
     *     storage space.
     * @return string A serialized form of those parts of the FinTs instance that can reasonably be persisted (BPD, UPD,
     *     Kundensystem-ID, etc.). Note that this usually contains some user data (user's name, account names and
     *     sometimes a dialog ID that is equivalent to session cookie). So the returned string needs to be treated
     *     carefully (not written to log files, only to a database or other storage system that would normally be used
     *     for user data). The returned string never contains highly sensitive information (not the user's password or
     *     PIN), so it probably does not need to be encrypted. Treat it like a session cookie of the same bank.
     *     Note that this is not necessarily valid UTF-8, so you should store it as a BLOB column or raw bytes.
     */
    public function persist(bool $minimal = false): string
    {
        // IMPORTANT: Be sure not to include highly sensitive user information here.
        return serialize([ // This should match loadPersistedInstanceVersion1().
            2, // Version of the serialized format.
            $minimal ? null : $this->bpd,
            $minimal ? null : $this->allowedTanModes,
            $minimal ? null : $this->upd,
            $this->selectedTanMode,
            $this->selectedTanMedium,
            $this->kundensystemId,
            $this->dialogId,
            $this->messageNumber,
        ]);
    }

    public function __serialize(): array
    {
        throw new \LogicException('FinTs cannot be serialize()-ed, you should call persist() instead.');
    }

    public function __unserialize(array $data): void
    {
        throw new \LogicException(
            'FinTs cannot be unserialize()-ed, you should pass $persistedInstance to FinTs::new() instead.');
    }

    /**
     * Loads data from a previous {@link FinTs} instance, to reuse cached BPD/UPD information and/or to continue using
     * an ongoing session. The latter is necessary to complete a TAN request when the user provides the TAN in a fresh
     * PHP process.
     *
     * Unless it's not available to you at that time already, you can just pass the persisted instance into
     * {@link FinTs::new()} instead of calling this function.
     *
     * @param string $persistedInstance The return value of {@link persist()} of a previous FinTs instance, usually
     *     from an earlier PHP process. NOTE: Each persisted instance may be used only once and should be considered
     *     invalid afterwards. To continue the session, call {@link persist()} again.
     *
     * @throws \InvalidArgumentException
     */
    public function loadPersistedInstance(string $persistedInstance): void
    {
        $unserialized = unserialize($persistedInstance);
        if (!is_array($unserialized) || count($unserialized) === 0) {
            throw new \InvalidArgumentException("Invalid persistedInstance: '$persistedInstance'");
        }
        $version = $unserialized[0];
        $data = array_slice($unserialized, 1);
        if ($version === 2) {
            $this->loadPersistedInstanceVersion2($data);
        } else {
            throw new \InvalidArgumentException("Unknown persistedInstace version: '{$unserialized[0]}''");
        }
    }

    private function loadPersistedInstanceVersion2(array $data): void
    {
        list( // This should match persist().
            $this->bpd,
            $this->allowedTanModes,
            $this->upd,
            $this->selectedTanMode,
            $this->selectedTanMedium,
            $this->kundensystemId,
            $this->dialogId,
            $this->messageNumber,
        ) = $data;
    }

    /** @noinspection PhpUnused */
    public function getLogger(): SanitizingLogger
    {
        return $this->logger;
    }

    /**
     * @param LoggerInterface $logger The logger to use going forward. Note that it will be wrapped in a
     *     {@link SanitizingLogger} to protect sensitive information like usernames and PINs.
     */
    public function setLogger(LoggerInterface $logger): void
    {
        if ($logger instanceof SanitizingLogger) {
            $this->logger = $logger;
        } else {
            $this->logger = new SanitizingLogger($logger, [$this->options, $this->credentials]);
        }
    }

    /**
     * @param int $connectTimeout The number of seconds to wait before aborting a connection attempt to the bank server.
     * @param int $responseTimeout The number of seconds to wait before aborting a request to the bank server.
     * @noinspection PhpUnused
     */
    public function setTimeouts(int $connectTimeout, int $responseTimeout): void
    {
        $this->options->timeoutConnect = $connectTimeout;
        $this->options->timeoutResponse = $responseTimeout;
    }

    /**
     * Executes a strongly authenticated login action and returns it. With some banks, this requires a TAN.
     * @return DialogInitialization A {@link BaseAction} for the outcome of the login. You should check whether a TAN is
     *     needed using {@link BaseAction::needsTan()} and, if so, let the user complete the TAN request from
     *     {@link BaseAction::getTanRequest()} and then finish the login by passing the {@link BaseAction}
     *     returned here to {@link submitTan()} or {@link checkDecoupledSubmission()}. See {@link execute()} for
     *     details.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
     * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
     *     that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
     */
    public function login(): DialogInitialization
    {
        $this->requireTanMode();
        $this->ensureSynchronized();
        $this->messageNumber = 1;
        $login = new DialogInitialization($this->options, $this->requireCredentials(), $this->getSelectedTanMode(),
            $this->selectedTanMedium, $this->kundensystemId);
        $this->execute($login);
        return $login;
    }

    /**
     * Executes an action. Be sure to {@link login()} first. See the `\Fhp\Action` package for actions that can be
     * executed with this function. Note that, after this function returns, the action can be in the following states:
     * 1. If {@link BaseAction::needsTan()} returns true, the action isn't completed yet because needs a TAN or other
     *    kind of two-factor authentication (2FA). In this case, use {@link BaseAction::getTanRequest()} to get more
     *    information about the TAN/2FA that is needed. Your application then needs to interact with the user to obtain
     *    the TAN (which should be passed into {@link submitTan()}) or to have them complete the 2FA check (which can
     *    be verified with {@link checkDecoupledSubmission()}). Both of those functions require passing the same
     *    {@link BaseAction} argument as an argument, and once they succeed, the action will be in the same completed
     *    state as if it had been completed right away.
     * 2. If {@link BaseAction::needsPollingWait()} returns true, the action isn't completed yet because the server is
     *    still running some slow operation. Importantly, the server has not necessarily accepted the action yet, so it
     *    is absolutely required that the client keeps polling if they don't want the action to be abandoned.
     *    In this case, use {@link BaseAction::getPollingInfo()} to get more information on how frequently to poll, and
     *    do the polling through {@link pollAction()}.
     * 3. If {@link BaseAction::needsVopConfirmation()} returns true, the action isn't completed yet because the payee
     *    information couldn't be matched automatically, so an explicit confirmation from the user is required.
     *    In this case, use {@link BaseAction::getVopConfirmationRequest()} to get more information to display to the
     *    user, ask the user to confirm that they want to proceed with the action, and then call {@link confirmVop()}.
     * 4. If none of the above return true, the action was completed right away.
     *    Use the respective getters on the action instance to retrieve the result. In case the action fails, the
     *    corresponding exception will be thrown from this function.
     *
     * Tip: In practice, polling (2.) and confirmation (3.) are needed only for Verification of Payee. So if your
     * application only ever executes read-only actions like account statement fetching, but never executes any
     * transfers, instead of handling these cases you could simply assert that {@link BaseAction::needsPollingWait()}
     * and {@link BaseAction::needsVopConfirmation()} both return false.
     *
     * Note that all conditions above that leave the action in an incomplete state require some action from the client
     * application. These actions then change the state of the action again, but they don't necessarily complete it.
     * In practice, the typical sequence is: Maybe polling, maybe VOP confirmation, maybe TAN, done. That said, you
     * should ideally implement your application to deal with any sequence of states. Just execute the action, check
     * what's state it's in, resolve that state as appropriate, and then check again (using the same code as before). Do
     * this repeatedly until none of the special conditions above happen anymore, at which point the action is done.
     *
     * @param BaseAction $action The action to be executed. Its {@link BaseAction::isDone()} status will be updated when
     *     this function returns successfully.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
     * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
     *     that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
     */
    public function execute(BaseAction $action): void
    {
        if ($this->dialogId === null && !($action instanceof DialogInitialization)) {
            throw new \RuntimeException('Need to login (DialogInitialization) before executing other actions');
        }

        // Add the action's main request segments.
        $requestSegments = $action->getNextRequest($this->bpd, $this->upd);
        if (count($requestSegments) === 0) {
            return; // No request needed.
        }
        $message = MessageBuilder::create()->add($requestSegments);

        // Add HKTAN for authentication if necessary.
        if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) {
            if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) {
                $message->add(HKTANFactory::createProzessvariante2Step1(
                    $this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment));
            }
        }

        // Add HKVPP for VOP verification if necessary.
        $hkvpp = null;
        if ($this->bpd?->vopRequiredForRequest($requestSegments) !== null) {
            $hkvpp = VopHelper::createHKVPPForInitialRequest($this->bpd);
            $message->add($hkvpp);
        }

        // Construct the request message and tell the action about the segment numbers that were assigned.
        $request = $this->buildMessage($message, $this->getSelectedTanMode()); // This fills in the segment numbers.
        $action->setRequestSegmentNumbers(array_map(function ($segment) {
            /* @var BaseSegment $segment */
            return $segment->getSegmentNumber();
        }, $requestSegments));

        // Execute the request.
        $response = $this->sendMessage($request);
        $this->processServerResponse($action, $response, $hkvpp);
    }

    /**
     * Updates the state of this FinTs instance and of the `$action` based on the server's response.
     * See {@link execute()} for more documentation on the possible outcomes.
     * @param BaseAction $action The action for which the request was sent.
     * @param Message $response The response we just got from the server.
     * @param HKVPPv1|null $hkvpp The HKVPP segment, if any was present in the request.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
     * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
     *      that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
     */
    private function processServerResponse(BaseAction $action, Message $response, ?HKVPPv1 $hkvpp = null): void
    {
        $this->readBPD($response);

        // Detect if the bank wants a TAN.
        /** @var HITAN $hitan */
        $hitan = $response->findSegment(HITAN::class);
        // Note: Instead of DUMMY_REFERENCE, it's officially the 3076 Rueckmeldungscode that tells we don't need a TAN.
        if ($hitan !== null && $hitan->getAuftragsreferenz() !== HITAN::DUMMY_REFERENCE) {
            if ($hitan->tanProzess !== HKTAN::TAN_PROZESS_4) {
                throw new UnexpectedResponseException("Unsupported TAN request type $hitan->tanProzess");
            }
            if ($this->bpd === null || $this->kundensystemId === null) {
                throw new UnexpectedResponseException('Unexpected TAN request');
            }
            // NOTE: In case of a decoupled TAN mode, the response code 3955 must be present, but it seems useless to us.
            $action->setTanRequest($hitan);
            if ($action instanceof DialogInitialization) {
                $action->setDialogId($response->header->dialogId);
                $action->setMessageNumber($this->messageNumber);
            }
        }

        // Detect if the bank needs us to do something for Verification of Payee.
        if ($hkvpp != null) {
            if ($pollingInfo = VopHelper::checkPollingRequired($response, $hkvpp->getSegmentNumber())) {
                $action->setPollingInfo($pollingInfo);
                if ($action->needsTan()) {
                    throw new UnexpectedResponseException('Unexpected polling and TAN request in the same response.');
                }
                return;
            }
            if ($confirmationRequest = VopHelper::checkVopConfirmationRequired($response, $hkvpp->getSegmentNumber())) {
                $action->setVopConfirmationRequest($confirmationRequest);
                if ($action->needsTan()) {
                    if ($confirmationRequest->getVerificationResult() === VopVerificationResult::CompletedFullMatch) {
                        // If someone hits this branch in practice, we can implement it.
                        throw new UnsupportedException('Combined VOP match confirmation and TAN request');
                    } else {
                        throw new UnexpectedResponseException(
                            'Unexpected TAN request on VOP result: ' . $confirmationRequest->getVerificationResult()
                        );
                    }
                }
            }
        }

        if ($action->needsVopConfirmation() || $action->needsTan()) {
            return; // The action isn't complete yet.
        }

        // If no TAN or VOP is needed, process the response normally, and maybe keep going for more pages.
        $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers()));
        if ($action instanceof PaginateableAction && $action->hasMorePages()) {
            $this->execute($action);
        }

        // Check whether the server requested a Kundensystem-ID refresh.
        if ($response->findRueckmeldung(Rueckmeldungscode::NEUE_KUNDENSYSTEM_ID_HOLEN) !== null) {
            // TODO Properly implement the refresh here, see https://github.com/nemiah/phpFinTS/issues/458.
            $this->logger->warning(
                'The server asked us to refresh the Kundensystem-ID in response to a ' . gettype($action) .
                ' action, but that is not implemented yet. This could result in authentication errors or extraneous ' .
                ' re-authentication prompts from the bank.'
            );
        }
    }

    /**
     * For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns
     * `false`, this function sends the given $tan to the server to complete the action. By using {@link persist()},
     * this can be done asynchronously, i.e., not in the same PHP process as the original {@link execute()} call.
     *
     * After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there.
     * In practice, the action is fully completed after completing the decoupled submission.
     * In case the action fails, the corresponding exception will be thrown from this function.
     *
     * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf
     * Section B.4.2.1.1
     *
     * @param BaseAction $action The action to be completed.
     * @param string $tan The TAN entered by the user.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
     * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
     *     that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
     */
    public function submitTan(BaseAction $action, string $tan): void
    {
        // Check the action's state.
        $tanRequest = $action->getTanRequest();
        if ($tanRequest === null) {
            throw new \InvalidArgumentException('This action does not need a TAN');
        }
        if ($action instanceof DialogInitialization) {
            if ($this->dialogId !== null) {
                throw new \RuntimeException('Cannot init another dialog.');
            }
            $this->dialogId = $action->getDialogId();
            $this->messageNumber = $action->getMessageNumber();
        }

        // Construct the request.
        $tanMode = $this->requireTanMode();
        if ($tanMode instanceof NoPsd2TanMode) {
            throw new \InvalidArgumentException('Cannot submit TAN when the bank does not support PSD2');
        }
        if ($tanMode->isDecoupled()) {
            throw new \InvalidArgumentException('Cannot submit TAN for a decoupled TAN mode');
        }
        $message = MessageBuilder::create()
            ->add(HKTANFactory::createProzessvariante2Step2($tanMode, $tanRequest->getProcessId()));
        $request = $this->buildMessage($message, $tanMode, $tan);

        // Execute the request.
        $response = $this->sendMessage($request);
        $this->readBPD($response);

        // Ensure that the TAN was accepted.
        /** @var HITAN $hitan */
        $hitan = $response->findSegment(HITAN::class);
        if ($hitan === null) {
            throw new UnexpectedResponseException('HITAN missing after submitting TAN');
        }
        if ($hitan->getTanProzess() !== HKTAN::TAN_PROZESS_2 // We only support the case "(B)" in the specification.
            || $hitan->getAuftragsreferenz() !== $tanRequest->getProcessId()) {
            throw new UnexpectedResponseException("Bank has not accepted TAN: $hitan");
        }
        $action->setTanRequest(null);

        // Process the response normally, and maybe keep going for more pages.
        $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers()));
        if ($action instanceof PaginateableAction && $action->hasMorePages()) {
            $this->execute($action);
        }
    }

    /**
     * For an action where {@link BaseAction::needsTan()} returns `true` and {@link TanMode::isDecoupled()} returns
     * `true`, this function checks with the server whether the second factor authentication has been completed yet on
     * the secondary device of the user.
     * -   If so, this function returns `true` and the `$action` is then in any of the same states as after
     *     {@link execute()} (except {@link BaseAction::needsTan()} won't happen again). See there for documentation.
     *     In practice, the action is fully completed after completing the decoupled submission.
     * -   In case the action fails, the corresponding exception will be thrown from this function.
     * -   If the authentication has not been completed yet, this returns `false` and the action remains in its
     *     previous, uncompleted state.
     *
     * By using {@link persist()}, this function can be called asynchronously, i.e., not in the same PHP process as the
     * original {@link execute()} call.
     *
     * This function can be called repeatedly, subject to the delays specified in the {@link TanMode}.
     * IMPORTANT: Remember to re-{@link persist()} the {@link FinTs} instance after each
     * {@link checkDecoupledSubmission()} call.
     *
     * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2020-07-10_final_version.pdf
     * Section B.4.2.2
     *
     * @param BaseAction $action The action to be completed.
     * @return bool True if the decoupled authentication is done and the $action was completed or entered one of the
     *     other states documented on {@link execute()}.
     *     If false, the {@link TanRequest} inside the action has been updated, which *may* provide new/more
     *     instructions to the user, though probably it rarely does in practice.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
     * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
     *     that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
     */
    public function checkDecoupledSubmission(BaseAction $action): bool
    {
        // Check the action's state.
        $tanRequest = $action->getTanRequest();
        if ($tanRequest === null) {
            throw new \InvalidArgumentException('This action is not awaiting decoupled confirmation');
        }
        if ($action instanceof DialogInitialization) {
            if ($this->dialogId !== null) {
                throw new \RuntimeException('Cannot init another dialog.');
            }
            $this->dialogId = $action->getDialogId();
            $this->messageNumber = $action->getMessageNumber();
        }

        // (2a) Construct the request.
        $tanMode = $this->requireTanMode();
        if ($tanMode instanceof NoPsd2TanMode) {
            throw new \InvalidArgumentException('Cannot check decoupled status when the bank does not support PSD2');
        }
        if (!$tanMode->isDecoupled()) {
            throw new \InvalidArgumentException('Cannot check decoupled status for a non-decoupled TAN mode');
        }
        $message = MessageBuilder::create()
            ->add(HKTANFactory::createProzessvariante2StepS($tanMode, $tanRequest->getProcessId()));
        $request = $this->buildMessage($message, $tanMode);

        // Execute the request.
        $response = $this->sendMessage($request);
        $this->readBPD($response);

        // Determine if the decoupled authentication has completed. See section B.4.2.2.1.
        // There is always at least one HITAN segment with TAN-Prozess=S and the reference ID.
        // (2b) The response code 3956 indicates that the authentication is still outstanding. There could also be more
        //      information for the user in the HITAN challenge field.
        // (2c) Note that we only support the (B) variant here. There is additionally supposed to be a HITAN segment
        //      with TAN-Prozess=2 and the reference ID to indicate that the authentication has completed, though not
        //      all banks actually send this, as they seem to consider the absence of 3956 as sufficient for signaling
        //      success. In this case, the response also contains the response segments for the executed action, if any.
        $hitanProcessS = null;
        /** @var HITAN $hitan */
        foreach ($response->findSegments(HITAN::class) as $hitan) {
            if ($hitan->getAuftragsreferenz() !== $tanRequest->getProcessId()) {
                throw new UnexpectedResponseException('Unexpected Auftragsreferenz: ' . $hitan->getAuftragsreferenz());
            }
            if ($hitan->getTanProzess() === HKTAN::TAN_PROZESS_S) {
                $hitanProcessS = $hitan;
            }
        }
        if ($hitanProcessS === null) {
            throw new UnexpectedResponseException('Missing HITAN with tanProzess=S in the response');
        }
        if ($response->findRueckmeldungen(Rueckmeldungscode::STARKE_KUNDENAUTHENTIFIZIERUNG_NOCH_AUSSTEHEND)) {
            // The decoupled submission isn't complete yet. Update the TAN request, as the bank may have sent additional
            // instructions.
            $action->setTanRequest($hitanProcessS);
            if ($action instanceof DialogInitialization) {
                $this->dialogId = null;
                $action->setMessageNumber($this->messageNumber);
            }
            return false;
        }

        // The decoupled submission is complete and the action's result is included in the response.
        $action->setTanRequest(null);
        // Process the response normally, and maybe keep going for more pages.
        $this->processActionResponse($action, $response->filterByReferenceSegments($action->getRequestSegmentNumbers()));
        if ($action instanceof PaginateableAction && $action->hasMorePages()) {
            $this->execute($action);
        }
        return true;
    }

    /**
     * For an action where {@link BaseAction::needsPollingWait()} returns `true`, this function polls the server.
     * By using {@link persist()}, this can be done asynchronously, i.e., not in the same PHP process as the original
     * {@link execute()} call or the previous {@link pollAction()} call.
     *
     * After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there. In
     * particular, it's possible that the long-running operation on the server has not completed yet and thus
     * {@link BaseAction::needsPollingWait()} still returns `true`. In practice, actions often require VOP confirmation
     * or a TAN after the polling is over, though they can also complete right away.
     * In case the action fails, the corresponding exception will be thrown from this function.
     *
     * @param BaseAction $action The action to be completed.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
     * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
     *     that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
     * @link FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf
     * Section C.10.7.1.1 a)
     */
    public function pollAction(BaseAction $action): void
    {
        $pollingInfo = $action->getPollingInfo();
        if ($pollingInfo === null) {
            throw new \InvalidArgumentException('This action is not awaiting polling for a long-running operation');
        } elseif ($pollingInfo instanceof VopPollingInfo) {
            // Only send a new HKVPP.
            $hkvpp = VopHelper::createHKVPPForPollingRequest($this->bpd, $pollingInfo);
            $message = MessageBuilder::create()->add($hkvpp);

            // Execute the request and process the response.
            $response = $this->sendMessage($this->buildMessage($message, $this->getSelectedTanMode()));
            $action->setPollingInfo(null);
            $this->processServerResponse($action, $response, $hkvpp);
        } else {
            throw new \InvalidArgumentException('Unexpected PollingInfo type: ' . gettype($pollingInfo));
        }
    }

    /**
     * For an action where {@link BaseAction::needsVopConfirmation()} returns `true`, this function re-submits the
     * action with the additional confirmation from the user that they want to execute the transfer(s) after having
     * reviewed the information from the {@link VopConfirmationRequest}.
     * By using {@link persist()}, this can be done asynchronously, i.e., not in the same PHP process as the original
     * {@link execute()} call.
     *
     * After this function returns, the `$action` is in any of the same states as after {@link execute()}, see there. In
     * practice, actions often require a TAN after VOP is confirmed, though they can also complete right away.
     * In case the action fails, the corresponding exception will be thrown from this function.
     *
     * @param BaseAction $action The action to be completed.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server responds with a valid but unexpected message.
     * @throws ServerException When the server responds with a (FinTS-encoded) error message, which includes most things
     *     that can go wrong with the action itself, like wrong credentials, invalid IBANs, locked accounts, etc.
     * @link FinTS_3.0_Messages_Geschaeftsvorfaelle_VOP_1.01_2025_06_27_FV.pdf
     * Section C.10.7.1.2 a)
     */
    public function confirmVop(BaseAction $action): void
    {
        $vopConfirmationRequest = $action->getVopConfirmationRequest();
        if (!($vopConfirmationRequest instanceof VopConfirmationRequestImpl)) {
            throw new \InvalidArgumentException('Unexpected type: ' . gettype($vopConfirmationRequest));
        }
        // We need to send the original request again, plus HKVPA as the confirmation.
        $requestSegments = $action->getNextRequest($this->bpd, $this->upd);
        if (count($requestSegments) === 0) {
            throw new \AssertionError('Request unexpectedly became empty upon VOP confirmation');
        }
        $message = MessageBuilder::create()
            ->add($requestSegments)
            ->add(VopHelper::createHKVPAForConfirmation($vopConfirmationRequest));

        // Add HKTAN for authentication if necessary.
        if (!($this->getSelectedTanMode() instanceof NoPsd2TanMode)) {
            if (($needTanForSegment = $action->getNeedTanForSegment()) !== null) {
                $message->add(HKTANFactory::createProzessvariante2Step1(
                    $this->requireTanMode(), $this->selectedTanMedium, $needTanForSegment));
            }
        }

        // Construct the request message and tell the action about the segment numbers that were assigned.
        $request = $this->buildMessage($message, $this->getSelectedTanMode()); // This fills in the segment numbers.
        $action->setRequestSegmentNumbers(array_map(function ($segment) {
            /* @var BaseSegment $segment */
            return $segment->getSegmentNumber();
        }, $requestSegments));

        // Execute the request and process the response.
        $response = $this->sendMessage($this->buildMessage($message, $this->getSelectedTanMode()));
        $action->setVopConfirmationRequest(null);
        $this->processServerResponse($action, $response);
    }

    /**
     * Closes the session/dialog/connection, if open. This is equivalent to logging out. You should call this function
     * when you're done with all the actions, but NOT when you're persisting the instance to fulfill the TAN request of
     * an outstanding action.
     * This FinTs object remains usable even after closing the session. You can still {@link persist()} it to benefit
     * from cached BPD/UPD upon the next {@link login()}, for instance.
     * @throws ServerException When closing the dialog fails.
     */
    public function close(): void
    {
        if ($this->dialogId !== null) {
            $this->endDialog();
        }
        $this->disconnect();
    }

    /**
     * Assumes that the session/dialog (if any is open) is gone, but keeps any cached BPD/UPD for reuse (to allow for
     * faster re-login).
     * This can be called by the application using this library when it just restored this FinTs instance from the
     * persisted format after a long time, during which the session/dialog has most likely expired on the server side.
     */
    public function forgetDialog(): void
    {
        $this->dialogId = null;
    }

    /**
     * Before executing any actions that might require two-step authentication (like fetching a statement or initiating
     * a wire transfer), the user needs to pick a {@link TanMode}. Note that this does not always imply that the user
     * actually needs to enter a TAN every time, but they need to have picked the mode so that the system knows how to
     * deliver a TAN, if necesssary.
     * @return TanMode[] The TAN modes that are available to the user, indexed by their IDs.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN modes
     *     like it should according to the protocol, or when the dialog is not closed properly.
     * @throws ServerException When the server responds with an error.
     */
    public function getTanModes(): array
    {
        $this->ensureTanModesAvailable();
        $result = [];
        foreach ($this->allowedTanModes as $tanModeId) {
            if (!array_key_exists($tanModeId, $this->bpd->allTanModes)) {
                continue;
            }
            $result[$tanModeId] = $this->bpd->allTanModes[$tanModeId];
        }
        return $result;
    }

    /**
     * For TAN modes where {@link TanMode::needsTanMedium()} returns true, the user additionally needs to pick a TAN
     * medium. This function returns a list of possible TAN media. Note that, depending on the bank, this list may
     * contain all the user's TAN media, or just the ones that are compatible with the given $tanMode.
     * @param TanMode|int $tanMode Either a {@link TanMode} instance obtained from {@link getTanModes()} or its ID.
     * @return TanMedium[] A list of possible TAN media.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN media
     *     (which includes the case where the server does not support enumerating TAN media, which is indicated by
     *     {@link TanMode::needsTanMedium()} returning false), or when the dialog is not closed properly.
     * @throws ServerException When the server responds with an error.
     */
    public function getTanMedia($tanMode): array
    {
        if ($this->dialogId !== null) {
            $this->endDialog();
        }
        $this->ensureBpdAvailable();
        $this->ensureSynchronized();
        $getTanMedia = new GetTanMedia();

        // Execute the GetTanMedia request with the $tanMode swapped in temporarily.
        $oldTanMode = $this->selectedTanMode;
        $oldTanMedium = $this->selectedTanMedium;
        $this->selectedTanMode = $tanMode instanceof TanMode ? $tanMode->getId() : $tanMode;
        $this->selectedTanMedium = '';
        try {
            $this->executeWeakDialogInitialization('HKTAB');
            $this->execute($getTanMedia);
            $this->endDialog();
            return $getTanMedia->getTanMedia();
        } catch (UnexpectedResponseException|CurlException|ServerException $e) {
            throw $e;
        } finally {
            $this->selectedTanMode = $oldTanMode;
            $this->selectedTanMedium = $oldTanMedium;
        }
    }

    /**
     * @param TanMode|int $tanMode Either a {@link TanMode} instance obtained from {@link getTanModes()} or its ID.
     * @param TanMedium|string|null $tanMedium If the $tanMode has {@link TanMode::needsTanMedium()} set to true, this
     *     must be the value returned from {@link TanMedium::getName()} for one of the TAN media supported with that TAN
     *     mode. Use {@link getTanMedia()} to obtain a list of possible TAN media options.
     */
    public function selectTanMode($tanMode, $tanMedium = null): void
    {
        if (!is_int($tanMode) && !($tanMode instanceof TanMode)) {
            throw new \InvalidArgumentException('tanMode must be an int or a TanMode');
        }
        if ($tanMedium !== null && !is_string($tanMedium) && !($tanMedium instanceof TanMedium)) {
            throw new \InvalidArgumentException('tanMedium must be a string or a TanMedium');
        }
        $this->selectedTanMode = $tanMode instanceof TanMode ? $tanMode->getId() : $tanMode;
        $this->selectedTanMedium = $tanMedium instanceof TanMedium ? $tanMedium->getName() : $tanMedium;
    }

    /**
     * Fetches the BPD from the server, if they are not already present at the client, and then returns them. Note that
     * this does not require user login.
     * @return BPD The BPD from the bank.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly.
     * @throws ServerException When the server resopnds with an error.
     */
    public function getBpd(): BPD
    {
        $this->ensureBpdAvailable();
        return $this->bpd;
    }

    // ------------------------------------------------- IMPLEMENTATION ------------------------------------------------

    /**
     * Ensures that the latest BPD data is present by executing an anonymous dialog (including initialization and
     * termination of the dialog) if necessary. Executing this does not require (strong or any) authentication, and it
     * makes the {@link $bpd} available.
     *
     * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf
     * Section: C.5.1 (and also C.3.1.1)
     *
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server does not send the BPD or close the dialog properly.
     * @throws ServerException When the server resopnds with an error.
     */
    private function ensureBpdAvailable(): void
    {
        if ($this->bpd !== null) {
            return; // Nothing to do.
        }
        if ($this->dialogId !== null) {
            throw new \RuntimeException('Cannot init another dialog.');
        }
        if ($this->selectedTanMode === NoPsd2TanMode::ID || $this->selectedTanMode instanceof NoPsd2TanMode) {
            // For banks that don't support PSD2, we also don't use an anonymous dialog to obtain the BPD. The more
            // common procedure before PSD2 was to just get the BPD upon first login. Thus execute(DialogInitialization)
            // tolerates not having a BPD yet.
            return;
        }

        // We must always include HKTAN in order to signal that strong authentication (PSD2) is supported (section
        // B.4.3.1). As this is the first contact with the server, we don't know which HKTAN versions it supports, so we
        // just sent HKTANv6 as it's currently most supported by banks.
        $initRequest = Message::createPlainMessage(MessageBuilder::create()
            ->add(HKIDNv2::createAnonymous($this->options->bankCode))
            ->add(HKVVBv3::create($this->options, null, null)) // Pretend we have no BPD/UPD.
            ->add(HKTANv6::createDummy()));
        $initResponse = $this->sendMessage($initRequest);
        if (!$this->readBPD($initResponse)) {
            throw new UnexpectedResponseException('Did not receive BPD');
        }
        $this->dialogId = $initResponse->header->dialogId;
        $this->endDialog(true);
    }

    private function requireCredentials(): Credentials
    {
        if ($this->credentials === null) {
            throw new \LogicException('This action is not allowed on a FinTs instance without Credentials');
        }
        return $this->credentials;
    }

    /**
     * Ensures that the {@link $allowedTanModes} are available by executing a personalized, TAN-less dialog
     * initialization (and closing the dialog again), if necessary. Executing this only requires the {@link Credentials}
     * but no strong authentication.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server does not send the BPD, the Kundensystem-ID or the TAN modes
     *     like it should according to the protocol, or when the dialog is not closed properly.
     * @throws ServerException When the server responds with an error.
     */
    private function ensureTanModesAvailable(): void
    {
        if ($this->allowedTanModes === null) {
            $this->ensureBpdAvailable();
            $this->ensureSynchronized(); // The response here will contain 3920, which is written to $allowedTanModes.
            if ($this->allowedTanModes === null) {
                throw new UnexpectedResponseException('No TAN modes received');
            }
        }
    }

    /**
     * Ensures that we have a {@link $kundensystemId} by executing a synchronization dialog (and closing it again) if
     * if necessary. Executing this does not require strong authentication.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server does not send the BPD or the Kundensystem-ID, or when the
     *     dialog is not closed properly.
     * @throws ServerException When the server responds with an error.
     */
    private function ensureSynchronized(): void
    {
        if ($this->kundensystemId === null) {
            $this->ensureBpdAvailable();

            // Execute dialog initialization without a TAN mode/medium, so using the fake mode 999. While most banks
            // accept the real TAN mode for synchronization (as defined in the specification), some get confused by the
            // presence of anything other than 999 into thinking that strong authentication is required. And for those
            // banks that don't support PSD2, we just keep the dummy TAN mode, as they wouldn't even understand 999.
            $oldTanMode = $this->selectedTanMode;
            $oldTanMedium = $this->selectedTanMedium;
            if (!($this->selectedTanMode instanceof NoPsd2TanMode)) {
                $this->selectedTanMode = null;
            }
            $this->selectedTanMedium = null;
            try {
                $this->executeWeakDialogInitialization(null);
                if ($this->kundensystemId === null) {
                    throw new UnexpectedResponseException('No Kundensystem-ID retrieved from sync.');
                }
                $this->endDialog();
            } finally {
                $this->selectedTanMode = $oldTanMode;
                $this->selectedTanMedium = $oldTanMedium;
            }
        }
    }

    /**
     * If the selected TAN mode was provided as an int, resolves it to a full {@link TanMode} instance, which may
     * involve a request to the server to retrieve the BPD. Then returns it.
     * @return TanMode|null The current TAN mode, null if none was selected, never an int.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws ServerException When the server resopnds with an error during the BPD fetch.
     */
    public function getSelectedTanMode(): ?TanMode
    {
        if ($this->selectedTanMode === NoPsd2TanMode::ID) {
            $this->selectedTanMode = new NoPsd2TanMode();
        } elseif (is_int($this->selectedTanMode)) {
            $this->ensureBpdAvailable();
            if (!array_key_exists($this->selectedTanMode, $this->bpd->allTanModes)) {
                throw new \InvalidArgumentException("Unknown TAN mode: $this->selectedTanMode");
            }
            $this->selectedTanMode = $this->bpd->allTanModes[$this->selectedTanMode];
            if (!$this->selectedTanMode->isProzessvariante2()) {
                throw new UnsupportedException('Only supports Prozessvariante 2');
            }

            if ($this->selectedTanMode->needsTanMedium()) {
                if ($this->selectedTanMedium === null) {
                    throw new \InvalidArgumentException('tanMedium is mandatory for this tanMode');
                }
            } else {
                if ($this->selectedTanMedium !== null) {
                    throw new \InvalidArgumentException('tanMedium not allowed for this tanMode');
                }
            }
        }
        return $this->selectedTanMode;
    }

    /**
     * Like {@link getSelectedTanMode()}, but throws an exception if none was selected.
     * @return TanMode The current TAN mode.
     * @throws \RuntimeException If no TAN mode has been selected.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws ServerException When the server resopnds with an error during the BPD fetch.
     */
    private function requireTanMode(): TanMode
    {
        $tanMode = $this->getSelectedTanMode();
        if ($tanMode === null) {
            throw new \RuntimeException('selectTanMode() must be called before login() or execute()');
        }
        return $tanMode;
    }

    /**
     * Creates a new connection based on the {@link $options}. This can be overridden for unit testing purposes.
     * @return Connection A newly instantiated connection.
     */
    protected function newConnection(): Connection
    {
        return new Connection($this->options->url, $this->options->timeoutConnect, $this->options->timeoutResponse);
    }

    /**
     * Closes the physical connection, if necessary.
     */
    private function disconnect(): void
    {
        if ($this->connection !== null) {
            $this->connection->disconnect();
            $this->connection = null;
        }
    }

    /**
     * Passes the response segments to the action for post-processing of the response.
     * @param BaseAction $action The action to which the response belongs.
     * @param Message $fakeResponseMessage A messsage that contains the response segments for this action.
     * @throws UnexpectedResponseException When the server responded with a valid but unexpected message.
     */
    private function processActionResponse(BaseAction $action, Message $fakeResponseMessage): void
    {
        $action->processResponse($fakeResponseMessage);
        if ($action instanceof DialogInitialization) {
            $this->dialogId = $action->getDialogId();
            if ($this->kundensystemId === null && $action->getKundensystemId()) {
                $this->kundensystemId = $action->getKundensystemId();
            }
            if ($action->getUpd() !== null) {
                $this->upd = $action->getUpd();
            } elseif ($this->upd === null && $action->isStronglyAuthenticated()) {
                throw new UnexpectedResponseException('No UPD received');
            }
        }
    }

    /**
     * Initialize a personalized dialog with weak authentication (no two-step authentication, no TAN, using the fake
     * mode with ID 999 instead), which can be used for certain less sensitive business transactions, including HKTAB to
     * retrieve the TAN media list. This is for Authentifizierungsklasse 1 and 4 (conditionally).
     * @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2018-02-23_final_version.pdf
     * Section: B.3
     * @param string|null $hktanRef The identifier of the main PIN/TAN management segment to be executed in this dialog,
     *     or null for a general weakly authenticated dialog. See {@link DialogInitialization} for documentation.
     * @throws CurlException When the connection fails in a layer below the FinTS protocol.
     * @throws UnexpectedResponseException When the server does not send the BPD or the Kundensystem-ID as it should
     *     according to the protocol, when it asks for a TAN even though it shouldn't, or when the dialog is not closed
     *     properly.
     * @throws ServerException When the server responds with an error.
     */
    private function executeWeakDialogInitialization(?string $hktanRef): void
    {
        if ($this->dialogId !== null) {
            throw new \RuntimeException('Cannot init another dialog.');
        }

        $this->messageNumber = 1;
        $dialogInitialization = new DialogInitialization($this->options, $this->requireCredentials(),
            $this->getSelectedTanMode(), $this->selectedTanMedium, $this->kundensystemId, $hktanRef);
        $this->execute($dialogInitialization);
        if ($dialogInitialization->needsTan()) {
            throw new UnexpectedResponseException('Server asked for TAN on a dialog meant for weak authentication');
        }
    }

    /**
     * @param Message $response A response retrieved from the server that may or may not contain the BPD.
     * @return bool Whether the BPD was found in the response.
     */
    private function readBPD(Message $response): bool
    {
        if ($allowed = $response->findRueckmeldung(Rueckmeldungscode::ZUGELASSENE_VERFAHREN)) {
            $this->allowedTanModes = array_map('intval', $allowed->rueckmeldungsparameter);
        }
        if (!$response->hasSegment(HIBPAv3::class)) {
            return false;
        }
        $this->bpd = BPD::extractFromResponse($response);
        if (!$this->bpd->supportsPsd2() && !($this->selectedTanMode instanceof NoPsd2TanMode)) {
            throw new UnsupportedException('The bank does not support PSD2.');
        }
        return true;
    }

    /**
     * Closes the currently active dialog, if any. Note that this does *not* close the connection, it is possible to
     * open a new dialog on the same connection.
     * @param bool $isAnonymous If set to true, the HKEND message will not be wrapped into an encryption envelope.
     * @throws ServerException When the server responds with an error instead of closing the dialog. This means that
     *     the connection is tainted and can probably not be used for another dialog.
     */
    protected function endDialog(bool $isAnonymous = false): void
    {
        if ($this->connection === null) {
            $this->dialogId = null;
            return;
        }
        try {
            if ($this->dialogId !== null) {
                $message = MessageBuilder::create()->add(HKENDv1::create($this->dialogId));
                $request = $isAnonymous
                    ? Message::createPlainMessage($message)
                    : $this->buildMessage($message, $this->getSelectedTanMode());
                $response = $this->sendMessage($request);
                if ($response->findRueckmeldung(Rueckmeldungscode::BEENDET) === null) {
                    throw new UnexpectedResponseException(
                        'Server did not confirm dialog end, but did not send error either');
                }
            }
        } catch (CurlException $e) {
            // Ignore, we want to disconnect anyway.
        } catch (ServerException $e) {
            if ($e->hasError(Rueckmeldungscode::ABGEBROCHEN)) {
                // We wanted to end the dialog, but the server already canceled it before.
                $this->logger->warning("Dialog already ended: $e");
            } else {
                // Something else went wrong.
                throw $e;
            }
        } finally {
            $this->dialogId = null;
        }
    }

    /**
     * Injects FinTsOptions/BPD/UPD/Credentials information into the message.
     * @param MessageBuilder $message The message to be built.
     * @param TanMode|null $tanMode Optionally a TAN mode that will be used when sending this message, defaults to 999
     *     (single step).
     * @param string|null $tan Optionally a TAN to sign this message with.
     * @return Message The built message.
     */
    private function buildMessage(MessageBuilder $message, ?TanMode $tanMode = null, ?string $tan = null): Message
    {
        return Message::createWrappedMessage(
            $message,
            $this->options,
            $this->kundensystemId === null ? '0' : $this->kundensystemId,
            $this->requireCredentials(),
            $tanMode,
            $tan
        );
    }

    /**
     * Finalizes a message (conversion to wire format, filling in message number and size), sends it to the bank and
     * parses the response, plus logging.
     * @param MessageBuilder|Message $request The message to be sent.
     * @return Message The response from the server.
     * @throws CurlException When the request failed on the physical or TCP/HTTPS protocol level.
     * @throws ServerException When the response contains an error.
     */
    private function sendMessage($request): Message
    {
        if ($request instanceof MessageBuilder) {
            $request = $this->buildMessage($request, $this->getSelectedTanMode());
        }

        $request->header->dialogId = $this->dialogId === null ? '0' : $this->dialogId;
        $request->header->nachrichtennummer = $this->messageNumber;
        $request->footer->nachrichtennummer = $this->messageNumber;
        ++$this->messageNumber;
        $request->header->setNachrichtengroesse(strlen($request->serialize()));

        $request->validate();

        if ($this->connection === null) {
            $this->connection = $this->newConnection();
        }

        $rawRequest = $request->serialize();
        $this->logger->debug('> ' . $rawRequest);
        try {
            $rawResponse = $this->connection->send($rawRequest);
            $this->logger->debug('< ' . $rawResponse);
        } catch (CurlException $e) {
            $this->logger->critical($e->getMessage());
            $this->logger->debug(print_r($e->getCurlInfo(), true));
            $this->disconnect();
            throw $e;
        }

        try {
            $response = Message::parse($rawResponse);
        } catch (\InvalidArgumentException $e) {
            $this->disconnect();
            throw new InvalidResponseException('Invalid response from server', 0, $e);
        }

        try {
            ServerException::detectAndThrowErrors($response, $request);
        } catch (ServerException $e) {
            $this->disconnect();
            if ($e->hasError(Rueckmeldungscode::ABGEBROCHEN)) {
                $this->forgetDialog();
            }
            throw $e;
        }
        return $response;
    }
}
