<?php

declare(strict_types=1);

namespace PHPMax\Transport;

use PHPMax\Exception\ProtocolException;

class ProxyConnector
{
     
    private $proxy;

    public function __construct(ProxyConfig $proxy)
    {
        $this->proxy = $proxy;
    }

    


    public function connect(string $targetHost, int $targetPort, bool $targetSsl, float $timeout)
    {
        $this->assertEndpoint($targetHost, $targetPort);
        if ($targetSsl && $this->proxy->scheme() === 'https') {
             
             
            throw new ProtocolException('TLS target over HTTPS proxy is not supported by PHP stream transport');
        }
        $deadline = microtime(true) + $this->normalizeTimeout($timeout);
        $stream = null;

        try {
            $stream = $this->connectProxy($this->remainingTimeout($deadline, 'proxy TCP connection'));

            if ($this->proxy->scheme() === 'https') {
                TlsStreamEnabler::enable(
                    $stream,
                    $this->proxy->host(),
                    $this->remainingTimeout($deadline, 'proxy TLS handshake')
                );
            }

            if ($this->proxy->isHttpConnect()) {
                $this->httpConnect($stream, $targetHost, $targetPort, $deadline);
            } elseif ($this->proxy->isSocks5()) {
                $this->socks5Connect($stream, $targetHost, $targetPort, $deadline);
            } else {
                throw new ProtocolException('Unsupported proxy scheme: ' . $this->proxy->scheme());
            }

            if ($targetSsl) {
                TlsStreamEnabler::enable(
                    $stream,
                    $targetHost,
                    $this->remainingTimeout($deadline, 'target TLS handshake')
                );
            }

            return $stream;
        } catch (\Throwable $e) {
            if (is_resource($stream)) {
                fclose($stream);
            }
            throw $e;
        }
    }

    


    private function connectProxy(float $timeout)
    {
        $errno = 0;
        $errstr = '';
        $target = 'tcp://' . $this->formatAuthority($this->proxy->host(), $this->proxy->port());

        set_error_handler(static function (): bool {
            return true;
        });
        try {
            $stream = stream_socket_client(
                $target,
                $errno,
                $errstr,
                $timeout,
                STREAM_CLIENT_CONNECT,
                stream_context_create()
            );
        } finally {
            restore_error_handler();
        }

        if (!is_resource($stream)) {
            throw new ProtocolException(sprintf(
                'Failed to connect to proxy %s: [%d] %s',
                $this->proxy->authority(),
                $errno,
                $errstr
            ));
        }
        stream_set_blocking($stream, true);

        return $stream;
    }

    


    private function httpConnect($stream, string $targetHost, int $targetPort, float $deadline): void
    {
        $target = $this->formatAuthority($targetHost, $targetPort);
        $headers = [
            'CONNECT ' . $target . ' HTTP/1.1',
            'Host: ' . $target,
        ];
        $auth = $this->proxy->basicAuthorizationHeader();
        if ($auth !== null) {
            $headers[] = $auth;
        }
        $headers[] = '';
        $headers[] = '';

        $this->writeAll($stream, implode("\r\n", $headers), $deadline);
        $response = $this->readHeaders($stream, $deadline);
        $firstLine = strtok($response, "\r\n");
        if ($firstLine === false || !preg_match('/^HTTP\/\S+\s+2\d\d\b/', $firstLine)) {
            throw new ProtocolException('HTTP proxy CONNECT failed');
        }
    }

    


    private function socks5Connect($stream, string $targetHost, int $targetPort, float $deadline): void
    {
        $methods = $this->proxy->username() !== null ? "\x00\x02" : "\x00";
        $this->writeAll($stream, "\x05" . chr(strlen($methods)) . $methods, $deadline);
        $selection = $this->readExact($stream, 2, $deadline);
        if ($selection[0] !== "\x05") {
            throw new ProtocolException('Invalid SOCKS5 proxy greeting');
        }

        $method = ord($selection[1]);
        if ($method === 0x02) {
            $this->socks5Authenticate($stream, $deadline);
        } elseif ($method !== 0x00) {
            throw new ProtocolException('SOCKS5 proxy rejected authentication methods');
        }

        $hostLength = strlen($targetHost);
        if ($hostLength > 255) {
            throw new ProtocolException('SOCKS5 target host is too long');
        }

        $request = "\x05\x01\x00\x03" . chr($hostLength) . $targetHost . pack('n', $targetPort);
        $this->writeAll($stream, $request, $deadline);
        $head = $this->readExact($stream, 4, $deadline);
        if ($head[0] !== "\x05" || ord($head[1]) !== 0x00) {
            throw new ProtocolException('SOCKS5 proxy CONNECT failed');
        }

        $atype = ord($head[3]);
        if ($atype === 0x01) {
            $this->readExact($stream, 4, $deadline);
        } elseif ($atype === 0x03) {
            $length = ord($this->readExact($stream, 1, $deadline));
            $this->readExact($stream, $length, $deadline);
        } elseif ($atype === 0x04) {
            $this->readExact($stream, 16, $deadline);
        } else {
            throw new ProtocolException('Invalid SOCKS5 response address type');
        }
        $this->readExact($stream, 2, $deadline);
    }

    


    private function socks5Authenticate($stream, float $deadline): void
    {
        $username = (string) $this->proxy->username();
        $password = (string) $this->proxy->password();
        if (strlen($username) > 255 || strlen($password) > 255) {
            throw new ProtocolException('SOCKS5 username/password is too long');
        }

        $request = "\x01" . chr(strlen($username)) . $username . chr(strlen($password)) . $password;
        $this->writeAll($stream, $request, $deadline);
        $response = $this->readExact($stream, 2, $deadline);
        if ($response[0] !== "\x01" || $response[1] !== "\x00") {
            throw new ProtocolException('SOCKS5 authentication failed');
        }
    }

    


    private function readHeaders($stream, float $deadline): string
    {
        $headers = '';
        while (strpos($headers, "\r\n\r\n") === false) {
            $headers .= $this->readExact($stream, 1, $deadline);
            if (strlen($headers) > 16384) {
                throw new ProtocolException('Proxy response headers are too large');
            }
        }

        return $headers;
    }

    


    private function readExact($stream, int $length, float $deadline): string
    {
        $data = '';
        while (strlen($data) < $length) {
            $remaining = $this->remainingTimeout($deadline, 'proxy read');
            $seconds = (int) floor($remaining);
            $microseconds = (int) max(0, ($remaining - $seconds) * 1000000);
            stream_set_timeout($stream, $seconds, $microseconds);

            $chunk = fread($stream, $length - strlen($data));
            if ($chunk === false) {
                throw new ProtocolException('Failed to read from proxy');
            }
            if ($chunk === '') {
                $meta = stream_get_meta_data($stream);
                if (!empty($meta['timed_out'])) {
                    throw new ProtocolException('Timed out reading from proxy');
                }
                if (feof($stream)) {
                    throw new ProtocolException('Proxy closed connection');
                }
                continue;
            }
            $data .= $chunk;
        }

        return $data;
    }

    


    private function writeAll($stream, string $data, float $deadline): void
    {
        $written = 0;
        $length = strlen($data);
        while ($written < $length) {
            $remaining = $this->remainingTimeout($deadline, 'proxy write');
            $seconds = (int) floor($remaining);
            $microseconds = (int) max(0, ($remaining - $seconds) * 1000000);
            stream_set_timeout($stream, $seconds, $microseconds);

            $chunk = fwrite($stream, substr($data, $written));
            if ($chunk === false || $chunk === 0) {
                $meta = stream_get_meta_data($stream);
                if (!empty($meta['timed_out'])) {
                    throw new ProtocolException('Timed out writing to proxy');
                }
                throw new ProtocolException('Failed to write to proxy');
            }
            $written += $chunk;
        }
    }

    private function assertEndpoint(string $host, int $port): void
    {
        if (trim($host) === '' || $port <= 0 || $port > 65535) {
            throw new ProtocolException('Invalid proxy target host or port');
        }
    }

    private function normalizeTimeout(float $timeout): float
    {
        if ($timeout <= 0.0) {
            return 1.0;
        }

        return max(0.001, $timeout);
    }

    private function remainingTimeout(float $deadline, string $operation): float
    {
        $remaining = $deadline - microtime(true);
        if ($remaining <= 0.0) {
            throw new ProtocolException('Timed out during ' . $operation);
        }

        return $this->normalizeTimeout($remaining);
    }

    private function formatAuthority(string $host, int $port): string
    {
        $formattedHost = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
            ? '[' . $host . ']'
            : $host;

        return $formattedHost . ':' . $port;
    }
}
