<?php

declare(strict_types=1);

namespace PhpWA\Client;

use PhpWA\Auth\AuthenticationCredentials;
use PhpWA\Auth\ClientPayloadFactory;
use PhpWA\Auth\NoiseCertificateVerifier;
use PhpWA\Exception\ConnectionClosedException;
use PhpWA\Exception\ProtocolException;
use PhpWA\Exception\TimeoutException;
use PhpWA\Protocol\Binary\BinaryCodec;
use PhpWA\Protocol\Binary\BinaryNode;
use PhpWA\Runtime\Deadline;
use PhpWA\Transport\Noise\NoiseTransportInterface;

/**
 * The only WABinary entry point above Noise. It deliberately owns no durable
 * state: authentication and message handlers commit state before responding.
 */
final class ProtocolConnection implements ProtocolConnectionInterface
{
    private const STATE_NEW = 'new';
    private const STATE_OPEN = 'open';
    private const STATE_CLOSED = 'closed';
    private const STATE_FAILED = 'failed';

    /** @var NoiseTransportInterface */ private $transport;
    /** @var ClientPayloadFactory */ private $payloads;
    /** @var BinaryCodec */ private $binary;
    /** @var callable */ private $certificateVerifier;
    /** @var string */ private $state = self::STATE_NEW;

    public function __construct(
        NoiseTransportInterface $transport,
        ?ClientPayloadFactory $payloads = null,
        ?BinaryCodec $binary = null,
        ?callable $certificateVerifier = null
    ) {
        $this->transport = $transport;
        $this->payloads = $payloads ?? new ClientPayloadFactory();
        $this->binary = $binary ?? new BinaryCodec();
        if ($certificateVerifier === null) {
            $verifier = new NoiseCertificateVerifier();
            $certificateVerifier = [$verifier, 'verify'];
        }
        $this->certificateVerifier = $certificateVerifier;
    }

    public function open(AuthenticationCredentials $credentials, Deadline $deadline): void
    {
        if ($this->state !== self::STATE_NEW) {
            throw new ProtocolException('Protocol connection instances may be opened only once.');
        }
        try {
            $payload = $this->payloads->forCredentials($credentials)->serializeToString();
            $this->transport->open(
                $credentials->noiseKey(),
                $credentials->routingInfo(),
                $payload,
                $this->certificateVerifier,
                $deadline
            );
            if (!$this->transport->isOpen()) {
                throw new ConnectionClosedException('Noise transport closed during protocol login.');
            }
            $this->state = self::STATE_OPEN;
        } catch (\Throwable $error) {
            $this->fail();
            throw $error;
        }
    }

    public function send(BinaryNode $node, Deadline $deadline): void
    {
        $this->assertOpen();
        try {
            $this->transport->sendPayload($this->binary->encode($node), $deadline);
        } catch (\Throwable $error) {
            $this->fail();
            throw $error;
        }
    }

    public function receive(Deadline $deadline): BinaryNode
    {
        $this->assertOpen();
        try {
            return $this->binary->decode($this->transport->receivePayload($deadline));
        } catch (TimeoutException $error) {
            // Bounded idle reads are checkpoints for run guards and heartbeats,
            // not evidence that the underlying Noise/WebSocket session failed.
            throw $error;
        } catch (\Throwable $error) {
            $this->fail();
            throw $error;
        }
    }

    public function close(): void
    {
        if ($this->state === self::STATE_CLOSED) { return; }
        try {
            $this->transport->close();
        } finally {
            $this->state = self::STATE_CLOSED;
        }
    }

    public function isOpen(): bool
    {
        return $this->state === self::STATE_OPEN && $this->transport->isOpen();
    }

    public function state(): string { return $this->state; }

    private function assertOpen(): void
    {
        if (!$this->isOpen()) {
            throw new ConnectionClosedException('Protocol connection is not open.');
        }
    }

    private function fail(): void
    {
        $this->state = self::STATE_FAILED;
        try { $this->transport->close(); } catch (\Throwable $ignored) {}
    }
}
