<?php

declare(strict_types=1);

namespace League\Flysystem\PhpseclibV3;

use League\Flysystem\FilesystemException;
use phpseclib3\Crypt\Common\AsymmetricKey;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Exception\NoKeyLoadedException;
use phpseclib3\Net\SFTP;
use phpseclib3\System\SSH\Agent;
use Throwable;

use function base64_decode;
use function implode;
use function str_split;

class SftpConnectionProvider implements ConnectionProvider
{

    /**
     * @var SFTP|null
     */
    private $connection;

    /**
     * @var ConnectivityChecker
     */
    private $connectivityChecker;

    public function __construct(
        private string $host,
        private string $username,
        private ?string $password = null,
        private ?string $privateKey = null,
        private ?string $passphrase = null,
        private int $port = 22,
        private bool $useAgent = false,
        private int $timeout = 10,
        private int $maxTries = 4,
        private string|array|null $hostFingerprint = null,
        ?ConnectivityChecker $connectivityChecker = null,
        private array $preferredAlgorithms = [],
        private bool $disableStatCache = true,
    ) {
        $this->connectivityChecker = $connectivityChecker ?? new SimpleConnectivityChecker();
    }

    public function provideConnection(): SFTP
    {
        $tries = 0;
        start:
        $tries++;

        try {
            $connection = $this->connection instanceof SFTP
                ? $this->connection
                : $this->setupConnection();
        } catch (Throwable $exception) {
            if ($tries <= $this->maxTries) {
                goto start;
            }

            if ($exception instanceof FilesystemException) {
                throw $exception;
            }

            throw UnableToConnectToSftpHost::atHostname($this->host, $exception);
        }

        if ( ! $this->connectivityChecker->isConnected($connection)) {
            $connection->disconnect();
            $this->connection = null;

            if ($tries <= $this->maxTries) {
                goto start;
            }

            throw UnableToConnectToSftpHost::atHostname($this->host);
        }

        return $this->connection = $connection;
    }

    public function disconnect(): void
    {
        if ($this->connection) {
            $this->connection->disconnect();
            $this->connection = null;
        }
    }

    private function setupConnection(): SFTP
    {
        $connection = new SFTP($this->host, $this->port, $this->timeout);
        $connection->setPreferredAlgorithms($this->preferredAlgorithms);
        $this->disableStatCache && $connection->disableStatCache();

        try {
            $this->checkFingerprint($connection);
            $this->authenticate($connection);
        } catch (Throwable $exception) {
            $connection->disconnect();
            throw $exception;
        }

        return $connection;
    }

    private function checkFingerprint(SFTP $connection): void
    {
        if ( ! $this->hostFingerprint) {
            return;
        }

        $publicKey = $connection->getServerPublicHostKey();

        if ($publicKey === false) {
            throw UnableToEstablishAuthenticityOfHost::becauseTheAuthenticityCantBeEstablished($this->host);
        }

        $fingerprint = $this->getFingerprintFromPublicKey($publicKey);
        $expectedFingerprints = is_array($this->hostFingerprint)
            ? $this->hostFingerprint
            : [$this->hostFingerprint];

        foreach ($expectedFingerprints as $expectedFingerprint) {
            if (0 === strcasecmp($expectedFingerprint, $fingerprint)) {
                return;
            }
        }

        throw UnableToEstablishAuthenticityOfHost::becauseTheAuthenticityCantBeEstablished($this->host);
    }

    private function getFingerprintFromPublicKey(string $publicKey): string
    {
        $content = explode(' ', $publicKey, 3);
        $algo = $content[0] === 'ssh-rsa' ? 'md5' : 'sha512';

        return implode(':', str_split(hash($algo, base64_decode($content[1])), 2));
    }

    private function authenticate(SFTP $connection): void
    {
        if ($this->privateKey !== null) {
            $this->authenticateWithPrivateKey($connection);
        } elseif ($this->useAgent) {
            $this->authenticateWithAgent($connection);
        } else {
            $this->authenticateWithUsernameAndPassword($connection);
        }
    }

    private function authenticateWithUsernameAndPassword(SFTP $connection): void
    {
        if ( ! $connection->login($this->username, $this->password)) {
            throw UnableToAuthenticate::withPassword($connection->getLastError());
        }
    }

    public static function fromArray(array $options): SftpConnectionProvider
    {
        return new SftpConnectionProvider(
            $options['host'],
            $options['username'],
            $options['password'] ?? null,
            $options['privateKey'] ?? null,
            $options['passphrase'] ?? null,
            $options['port'] ?? 22,
            $options['useAgent'] ?? false,
            $options['timeout'] ?? 10,
            $options['maxTries'] ?? 4,
            $options['hostFingerprint'] ?? null,
            $options['connectivityChecker'] ?? null,
            $options['preferredAlgorithms'] ?? [],
        );
    }

    private function authenticateWithPrivateKey(SFTP $connection): void
    {
        $privateKey = $this->loadPrivateKey();

        if ($connection->login($this->username, $privateKey)) {
            return;
        }

        if ($this->password !== null && $connection->login($this->username, $this->password)) {
            return;
        }

        throw UnableToAuthenticate::withPrivateKey($connection->getLastError());
    }

    private function loadPrivateKey(): AsymmetricKey
    {
        if (("---" !== substr($this->privateKey, 0, 3) || "PuTTY" !== substr($this->privateKey, 0, 5)) && is_file($this->privateKey)) {
            $this->privateKey = file_get_contents($this->privateKey);
        }

        try {
            if ($this->passphrase !== null) {
                return PublicKeyLoader::load($this->privateKey, $this->passphrase);
            }

            return PublicKeyLoader::load($this->privateKey);
        } catch (NoKeyLoadedException $exception) {
            throw new UnableToLoadPrivateKey(null, $exception);
        }
    }

    private function authenticateWithAgent(SFTP $connection): void
    {
        $agent = new Agent();

        if ( ! $connection->login($this->username, $agent)) {
            throw UnableToAuthenticate::withSshAgent($connection->getLastError());
        }
    }
}
