<?php

namespace MongoDB\Tests\SpecTests;

use Exception;
use MongoDB\Driver\Exception\BulkWriteException;
use MongoDB\Driver\Exception\CommandException;
use MongoDB\Driver\Exception\ExecutionTimeoutException;
use MongoDB\Driver\Exception\RuntimeException;
use MongoDB\Exception\InvalidArgumentException;
use MongoDB\Tests\TestCase;
use stdClass;
use Throwable;

use function is_array;
use function is_string;
use function sprintf;

/**
 * Spec test operation error expectation.
 */
final class ErrorExpectation
{
    /**
     * @see https://github.com/mongodb/mongo/blob/master/src/mongo/base/error_codes.err
     * @var array<string, int>
     */
    private static array $codeNameMap = [
        'Interrupted' => 11601,
        'MaxTimeMSExpired' => 50,
        'NoSuchTransaction' => 251,
        'OperationNotSupportedInTransaction' => 263,
        'WriteConflict' => 112,
    ];

    private int $code;

    private string $codeName;

    private bool $isExpected = false;

    /** @var list<string> */
    private array $excludedLabels = [];

    /** @var list<string> */
    private array $includedLabels = [];

    private string $messageContains;

    private function __construct()
    {
    }

    public static function fromClientSideEncryption(stdClass $operation)
    {
        return self::fromGenericOperation($operation);
    }

    public static function fromCrud(stdClass $result)
    {
        $o = new self();

        if (isset($result->error)) {
            $o->isExpected = $result->error;
        }

        return $o;
    }

    public static function fromReadWriteConcern(stdClass $operation)
    {
        return self::fromGenericOperation($operation);
    }

    public static function fromRetryableReads(stdClass $operation)
    {
        $o = new self();

        if (isset($operation->error)) {
            $o->isExpected = $operation->error;
        }

        return $o;
    }

    /** @throws InvalidArgumentException */
    public static function fromTransactions(stdClass $operation)
    {
        return self::fromGenericOperation($operation);
    }

    public static function noError()
    {
        return new self();
    }

    /**
     * Assert that the error expectation matches the actual outcome.
     *
     * @param TestCase       $test   Test instance for performing assertions
     * @param Exception|null $actual Exception (if any) from the actual outcome
     */
    public function assert(TestCase $test, ?Throwable $actual = null): void
    {
        if (! $this->isExpected) {
            if ($actual !== null) {
                $test->fail(sprintf("Operation threw unexpected %s: %s\n%s", $actual::class, $actual->getMessage(), $actual->getTraceAsString()));
            }

            $test->addToAssertionCount(1);

            return;
        }

        $test->assertNotNull($actual);

        if (isset($this->messageContains) && $this->messageContains !== '') {
            $test->assertStringContainsStringIgnoringCase($this->messageContains, $actual->getMessage());
        }

        if (isset($this->codeName)) {
            $this->assertCodeName($test, $actual);
        }

        if (! empty($this->excludedLabels) || ! empty($this->includedLabels)) {
            $test->assertInstanceOf(RuntimeException::class, $actual);

            foreach ($this->excludedLabels as $label) {
                $test->assertFalse($actual->hasErrorLabel($label), 'Exception should not have error label: ' . $label);
            }

            foreach ($this->includedLabels as $label) {
                $test->assertTrue($actual->hasErrorLabel($label), 'Exception should have error label: ' . $label);
            }
        }
    }

    public function isExpected()
    {
        return $this->isExpected;
    }

    /**
     * Assert that the error code name expectation matches the actual outcome.
     *
     * @param TestCase       $test   Test instance for performing assertions
     * @param Exception|null $actual Exception (if any) from the actual outcome
     */
    private function assertCodeName(TestCase $test, ?Throwable $actual = null): void
    {
        /* BulkWriteException does not expose codeName for server errors. Work
         * around this be comparing the error code against a map.
         *
         * TODO: Remove this once PHPC-1386 is resolved. */
        if ($actual instanceof BulkWriteException || $actual instanceof ExecutionTimeoutException) {
            $test->assertArrayHasKey($this->codeName, self::$codeNameMap);
            $test->assertSame(self::$codeNameMap[$this->codeName], $actual->getCode());

            return;
        }

        $test->assertInstanceOf(CommandException::class, $actual);
        $result = $actual->getResultDocument();

        if (isset($result->writeConcernError)) {
            $test->assertObjectHasProperty('codeName', $result->writeConcernError);
            $test->assertSame($this->codeName, $result->writeConcernError->codeName);

            return;
        }

        $test->assertObjectHasProperty('codeName', $result);
        $test->assertSame($this->codeName, $result->codeName);
    }

    /** @throws InvalidArgumentException */
    private static function fromGenericOperation(stdClass $operation)
    {
        $o = new self();

        if (isset($operation->error)) {
            $o->isExpected = $operation->error;
        }

        $result = $operation->result ?? null;

        if (isset($result->errorContains)) {
            $o->messageContains = $result->errorContains;
            $o->isExpected = true;
        }

        if (isset($result->errorCodeName)) {
            $o->codeName = $result->errorCodeName;
            $o->isExpected = true;
        }

        if (isset($result->errorLabelsContain)) {
            if (! self::isArrayOfStrings($result->errorLabelsContain)) {
                throw InvalidArgumentException::invalidType('errorLabelsContain', $result->errorLabelsContain, 'string[]');
            }

            $o->includedLabels = $result->errorLabelsContain;
            $o->isExpected = true;
        }

        if (isset($result->errorLabelsOmit)) {
            if (! self::isArrayOfStrings($result->errorLabelsOmit)) {
                throw InvalidArgumentException::invalidType('errorLabelsOmit', $result->errorLabelsOmit, 'string[]');
            }

            $o->excludedLabels = $result->errorLabelsOmit;
            $o->isExpected = true;
        }

        return $o;
    }

    private static function isArrayOfStrings($array)
    {
        if (! is_array($array)) {
            return false;
        }

        foreach ($array as $string) {
            if (! is_string($string)) {
                return false;
            }
        }

        return true;
    }
}
