<?php

declare(strict_types=1);

/*
 * This file is part of PHPUnit.
 *
 * (c) Sebastian Bergmann <sebastian@phpunit.de>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace PHPUnit\Runner\TestRunHistory;

use const DIRECTORY_SEPARATOR;
use const LOCK_EX;
use const LOCK_UN;

use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\Runner\DirectoryDoesNotExistException;
use PHPUnit\Runner\Exception;
use PHPUnit\Util\Filesystem;

use function array_keys;
use function dirname;
use function fclose;
use function file_get_contents;
use function flock;
use function fopen;
use function ftruncate;
use function fwrite;
use function is_array;
use function is_dir;
use function is_file;
use function is_float;
use function is_int;
use function is_string;
use function json_decode;
use function json_encode;
use function Pest\version;
use function rewind;
use function stream_get_contents;

/**
 * @internal This class is not covered by the backward compatibility promise for PHPUnit
 */
final class DefaultTestRunHistory implements TestRunHistory
{
    private const string DEFAULT_FILENAME = '.phpunit.result.cache';

    private readonly string $filename;

    /**
     * @var array<string, TestStatus>
     */
    private array $defects = [];

    /**
     * @var array<string, float>
     */
    private array $times = [];

    /**
     * @var array<string, true>
     */
    private array $changedDefects = [];

    /**
     * @var array<string, true>
     */
    private array $changedTimes = [];

    public function __construct(?string $filepath = null)
    {
        if ($filepath !== null && is_dir($filepath)) {
            $filepath .= DIRECTORY_SEPARATOR.self::DEFAULT_FILENAME;
        }

        $this->filename = $filepath ?? $_ENV['PHPUNIT_RESULT_CACHE'] ?? self::DEFAULT_FILENAME;
    }

    public function setStatus(TestRunHistoryId $id, TestStatus $status): void
    {
        if ($status->isSuccess()) {
            return;
        }

        $this->defects[$id->asString()] = $status;
        $this->changedDefects[$id->asString()] = true;
    }

    public function remove(TestRunHistoryId $id): void
    {
        unset($this->defects[$id->asString()]);

        $this->changedDefects[$id->asString()] = true;
    }

    public function status(TestRunHistoryId $id): TestStatus
    {
        return $this->defects[$id->asString()] ?? TestStatus::unknown();
    }

    public function setTime(TestRunHistoryId $id, float $time): void
    {
        $this->times[$id->asString()] = $time;
        $this->changedTimes[$id->asString()] = true;
    }

    public function time(TestRunHistoryId $id): float
    {
        return $this->times[$id->asString()] ?? 0.0;
    }

    public function mergeWith(self $other): void
    {
        foreach ($other->defects as $id => $defect) {
            $this->defects[$id] = $defect;
            $this->changedDefects[$id] = true;
        }

        foreach ($other->times as $id => $time) {
            $this->times[$id] = $time;
            $this->changedTimes[$id] = true;
        }
    }

    public function load(): void
    {
        if (! is_file($this->filename)) {
            return;
        }

        $contents = file_get_contents($this->filename);

        if ($contents === false) {
            // @codeCoverageIgnoreStart
            return;
            // @codeCoverageIgnoreEnd
        }

        $parsed = $this->parse($contents);

        if ($parsed === null) {
            return;
        }

        [$this->defects, $this->times] = $parsed;

        $this->changedDefects = [];
        $this->changedTimes = [];
    }

    /**
     * @throws Exception
     */
    public function persist(): void
    {
        $this->writeToFile(false);
    }

    /**
     * @throws Exception
     */
    public function persistAndPrune(): void
    {
        $this->writeToFile(true);
    }

    /**
     * @throws Exception
     */
    private function writeToFile(bool $prune): void
    {
        if (! Filesystem::createDirectory(dirname($this->filename))) {
            throw new DirectoryDoesNotExistException(dirname($this->filename));
        }

        $handle = fopen($this->filename, 'c+');

        if ($handle === false) {
            // @codeCoverageIgnoreStart
            return;
            // @codeCoverageIgnoreEnd
        }

        flock($handle, LOCK_EX);

        if ($prune) {
            $defects = [];

            foreach ($this->defects as $id => $status) {
                if (isset($this->changedDefects[$id])) {
                    $defects[$id] = $status;
                }
            }

            $times = [];

            foreach ($this->times as $id => $time) {
                if (isset($this->changedTimes[$id]) || isset($this->changedDefects[$id])) {
                    $times[$id] = $time;
                }
            }
        } else {
            $parsed = $this->parse((string) stream_get_contents($handle));

            if ($parsed !== null) {
                [$defects, $times] = $parsed;

                foreach (array_keys($this->changedDefects) as $id) {
                    if (isset($this->defects[$id])) {
                        $defects[$id] = $this->defects[$id];
                    } else {
                        unset($defects[$id]);
                    }
                }

                foreach ($this->times as $id => $time) {
                    if (isset($this->changedTimes[$id])) {
                        $times[$id] = $time;
                    }
                }
            } else {
                $defects = $this->defects;
                $times = $this->times;
            }
        }

        $data = [
            'version' => $this->version(),
            'defects' => [],
            'times' => $times,
        ];

        foreach ($defects as $test => $status) {
            $data['defects'][$test] = $status->asInt();
        }

        $json = json_encode($data);

        if ($json !== false) {
            ftruncate($handle, 0);
            rewind($handle);
            fwrite($handle, $json);
        }

        flock($handle, LOCK_UN);
        fclose($handle);
    }

    /**
     * @return ?array{0: array<string, TestStatus>, 1: array<string, float>}
     */
    private function parse(string $contents): ?array
    {
        $data = json_decode(
            $contents,
            true,
        );

        if (! is_array($data)) {
            return null;
        }

        if (! isset($data['version']) || $data['version'] !== $this->version()) {
            return null;
        }

        if (! isset($data['defects'], $data['times']) || ! is_array($data['defects']) || ! is_array($data['times'])) {
            return null;
        }

        $defects = [];

        foreach ($data['defects'] as $test => $status) {
            if (! is_string($test) || ! is_int($status)) {
                continue;
            }

            $defects[$test] = TestStatus::from($status);
        }

        $times = [];

        foreach ($data['times'] as $test => $time) {
            if (! is_string($test) || (! is_float($time) && ! is_int($time))) {
                continue;
            }

            $times[$test] = (float) $time;
        }

        return [$defects, $times];
    }

    private function version(): string
    {
        return 'pest_'.version();
    }
}
