<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop\Unit\Service\Bundle;

use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop\Service\Bundle\BundleCanonicalizer;
use Drupal\flowdrop\Service\Bundle\BundleSigner;
use Drupal\flowdrop\Service\Bundle\BundleVerifier;
use Drupal\flowdrop\Service\Bundle\TrustedPublisherResolverInterface;
use Drupal\flowdrop\Service\Bundle\VerificationResult;

/**
 * Tests the workflow-bundle canonicalizer, signer, and verifier.
 *
 * @group flowdrop
 * @covers \Drupal\flowdrop\Service\Bundle\BundleCanonicalizer
 * @covers \Drupal\flowdrop\Service\Bundle\BundleSigner
 * @covers \Drupal\flowdrop\Service\Bundle\BundleVerifier
 */
class BundleSignatureTest extends UnitTestCase {

  /**
   * The canonicalizer under test.
   */
  private BundleCanonicalizer $canonicalizer;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->canonicalizer = new BundleCanonicalizer();
  }

  /**
   * Key order and whitespace must not change the canonical bytes.
   */
  public function testCanonicalIsOrderAndWhitespaceInvariant(): void {
    $a = ['b' => 1, 'a' => ['z' => 2, 'y' => 3]];
    $b = ['a' => ['y' => 3, 'z' => 2], 'b' => 1];

    $this->assertSame(
      $this->canonicalizer->canonicalize($a),
      $this->canonicalizer->canonicalize($b),
    );
  }

  /**
   * List order is meaningful and must be preserved.
   */
  public function testCanonicalPreservesListOrder(): void {
    $this->assertNotSame(
      $this->canonicalizer->canonicalize(['nodes' => [1, 2, 3]]),
      $this->canonicalizer->canonicalize(['nodes' => [3, 2, 1]]),
    );
  }

  /**
   * Any value change must change the canonical bytes.
   */
  public function testCanonicalDetectsValueChange(): void {
    $this->assertNotSame(
      $this->canonicalizer->canonicalize(['v' => 'a']),
      $this->canonicalizer->canonicalize(['v' => 'b']),
    );
  }

  /**
   * A bundle signed by a trusted publisher verifies as trusted.
   */
  public function testSignedBundleByTrustedPublisherIsTrusted(): void {
    [$publicKey, $secretKey] = $this->keypair();
    $signer = new BundleSigner($this->canonicalizer);
    $verifier = new BundleVerifier($this->canonicalizer, $this->resolver(['acme' => $publicKey]));

    $bundle = $signer->sign($this->payload('acme'), 'acme', $secretKey);
    $result = $verifier->verify($bundle);

    $this->assertSame(VerificationResult::TRUSTED, $result->getStatus());
    $this->assertTrue($result->isTrusted());
    $this->assertSame('acme', $result->getKeyId());
  }

  /**
   * A tampered payload no longer verifies.
   */
  public function testTamperedPayloadIsUntrusted(): void {
    [$publicKey, $secretKey] = $this->keypair();
    $signer = new BundleSigner($this->canonicalizer);
    $verifier = new BundleVerifier($this->canonicalizer, $this->resolver(['acme' => $publicKey]));

    $bundle = $signer->sign($this->payload('acme'), 'acme', $secretKey);
    // Smuggle a different flow past the valid signature.
    $bundle['payload']['flow']['label'] = 'Evil';
    $result = $verifier->verify($bundle);

    $this->assertSame(VerificationResult::UNTRUSTED, $result->getStatus());
    $this->assertFalse($result->isTrusted());
  }

  /**
   * A valid signature from an unknown publisher is untrusted, not trusted.
   */
  public function testUnknownPublisherIsUntrusted(): void {
    [, $secretKey] = $this->keypair();
    $signer = new BundleSigner($this->canonicalizer);
    // Resolver knows no publishers.
    $verifier = new BundleVerifier($this->canonicalizer, $this->resolver([]));

    $bundle = $signer->sign($this->payload('acme'), 'acme', $secretKey);
    $result = $verifier->verify($bundle);

    $this->assertSame(VerificationResult::UNTRUSTED, $result->getStatus());
    $this->assertSame('acme', $result->getKeyId());
  }

  /**
   * A bundle with no signature envelope is reported unsigned.
   */
  public function testUnsignedBundle(): void {
    $verifier = new BundleVerifier($this->canonicalizer, $this->resolver([]));

    $result = $verifier->verify(['payload' => $this->payload('acme')]);

    $this->assertSame(VerificationResult::UNSIGNED, $result->getStatus());
  }

  /**
   * A payload whose publisher disagrees with the signature key id is rejected.
   */
  public function testPublisherKeyIdMismatchIsUntrusted(): void {
    [$publicKey, $secretKey] = $this->keypair();
    $signer = new BundleSigner($this->canonicalizer);
    $verifier = new BundleVerifier($this->canonicalizer, $this->resolver(['acme' => $publicKey]));

    $bundle = $signer->sign($this->payload('acme'), 'acme', $secretKey);
    // Re-stamp the envelope to claim a different publisher.
    $bundle['signature']['key_id'] = 'other';
    $result = $verifier->verify($bundle);

    $this->assertSame(VerificationResult::UNTRUSTED, $result->getStatus());
  }

  /**
   * An unsupported algorithm is rejected without attempting verification.
   */
  public function testUnsupportedAlgorithmIsUntrusted(): void {
    $verifier = new BundleVerifier($this->canonicalizer, $this->resolver([]));

    $result = $verifier->verify([
      'payload' => $this->payload('acme'),
      'signature' => ['alg' => 'rsa', 'key_id' => 'acme', 'value' => 'AAAA'],
    ]);

    $this->assertSame(VerificationResult::UNTRUSTED, $result->getStatus());
  }

  /**
   * Generates a raw Ed25519 keypair.
   *
   * @return array{0: string, 1: string}
   *   The [public key, secret key] pair.
   */
  private function keypair(): array {
    $keypair = sodium_crypto_sign_keypair();
    return [
      sodium_crypto_sign_publickey($keypair),
      sodium_crypto_sign_secretkey($keypair),
    ];
  }

  /**
   * Builds a sample bundle payload.
   *
   * @param string $publisher
   *   The publisher key id to stamp into the payload.
   *
   * @return array
   *   A payload array.
   */
  private function payload(string $publisher): array {
    return [
      'publisher' => $publisher,
      'created' => '2026-06-15T00:00:00+00:00',
      'flow' => [
        'id' => 'demo',
        'label' => 'Demo',
        'nodes' => [['id' => 'n1'], ['id' => 'n2']],
        'edges' => [],
      ],
      'requires' => ['flowdrop_node_processor:calculator'],
    ];
  }

  /**
   * Builds a fixed trusted-publisher resolver.
   *
   * @param array<string, string> $keys
   *   Map of key id to raw public key.
   *
   * @return \Drupal\flowdrop\Service\Bundle\TrustedPublisherResolverInterface
   *   The resolver.
   */
  private function resolver(array $keys): TrustedPublisherResolverInterface {
    return new class($keys) implements TrustedPublisherResolverInterface {

      /**
       * Constructs the fixed resolver.
       *
       * @param array<string, string> $keys
       *   Map of key id to raw public key.
       */
      public function __construct(private readonly array $keys) {}

      /**
       * {@inheritdoc}
       */
      public function getPublicKey(string $keyId): ?string {
        return $this->keys[$keyId] ?? NULL;
      }

    };
  }

}
