<?php declare(strict_types=1);
/*
 * This file is part of sebastian/diff.
 *
 * (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 SebastianBergmann\Diff\Utils;

use const PREG_SPLIT_DELIM_CAPTURE;
use const PREG_SPLIT_NO_EMPTY;
use function count;
use function is_array;
use function preg_match;
use function preg_split;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;
use RuntimeException;
use UnexpectedValueException;

trait UnifiedDiffAssertTrait
{
    /**
     * @throws UnexpectedValueException
     */
    public function assertValidUnifiedDiffFormat(string $diff): void
    {
        if ('' === $diff) {
            $this->addToAssertionCount(1);

            return;
        }

        // test diff ends with a line break
        $last = substr($diff, -1);

        if ("\n" !== $last && "\r" !== $last) {
            throw new UnexpectedValueException(sprintf('Expected diff to end with a line break, got "%s".', $last));
        }

        $lines = preg_split('/(.*\R)/', $diff, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);

        if (!is_array($lines)) {
            throw new RuntimeException('Failed to split diff into lines.');
        }

        $lineCount        = count($lines);
        $lineNumber       = $diffLineFromNumber = $diffLineToNumber = 1;
        $fromStart        = $fromTillOffset = $toStart = $toTillOffset = -1;
        $expectHunkHeader = true;

        // check for header
        if ($lineCount > 1) {
            $line1 = $lines[0] ?? throw new UnexpectedValueException('Line 1 missing.');
            $line2 = $lines[1] ?? throw new UnexpectedValueException('Line 2 missing.');

            $this->unifiedDiffAssertLinePrefix($line1, 'Line 1.');
            $this->unifiedDiffAssertLinePrefix($line2, 'Line 2.');

            if (str_starts_with($line1, '---')) {
                if (!str_starts_with($line2, '+++')) {
                    throw new UnexpectedValueException(sprintf("Line 1 indicates a header, so line 2 must start with \"+++\".\nLine 1: \"%s\"\nLine 2: \"%s\".", $line1, $line2));
                }

                $this->unifiedDiffAssertHeaderLine($line1, '--- ', 'Line 1.');
                $this->unifiedDiffAssertHeaderLine($line2, '+++ ', 'Line 2.');

                $lineNumber = 3;
            }
        }

        $endOfLineTypes = [];
        $diffClosed     = false;

        // assert format of lines, get all hunks, test the line numbers
        for (; $lineNumber <= $lineCount; $lineNumber++) {
            if ($diffClosed) {
                throw new UnexpectedValueException(sprintf('Unexpected line as 2 "No newline" markers have found, ". Line %d.', $lineNumber));
            }

            $line = $lines[$lineNumber - 1] ?? throw new UnexpectedValueException(sprintf('Line %d missing.', $lineNumber)); // line numbers start by 1, array index at 0
            $type = $this->unifiedDiffAssertLinePrefix($line, sprintf('Line %d.', $lineNumber));

            if ($expectHunkHeader && '@' !== $type && '\\' !== $type) {
                throw new UnexpectedValueException(sprintf('Expected hunk start (\'@\'), got "%s". Line %d.', $type, $lineNumber));
            }

            if ('@' === $type) {
                if (!$expectHunkHeader) {
                    throw new UnexpectedValueException(sprintf('Unexpected hunk start (\'@\'). Line %d.', $lineNumber));
                }

                $previousHunkFromEnd = $fromStart + $fromTillOffset;
                $previousHunkTillEnd = $toStart + $toTillOffset;

                [$fromStart, $fromTillOffset, $toStart, $toTillOffset] = $this->unifiedDiffAssertHunkHeader($line, sprintf('Line %d.', $lineNumber));

                // detect overlapping hunks
                if ($fromStart < $previousHunkFromEnd) {
                    throw new UnexpectedValueException(sprintf('Unexpected new hunk; "from" (\'-\') start overlaps previous hunk. Line %d.', $lineNumber));
                }

                if ($toStart < $previousHunkTillEnd) {
                    throw new UnexpectedValueException(sprintf('Unexpected new hunk; "to" (\'+\') start overlaps previous hunk. Line %d.', $lineNumber));
                }

                /* valid states; hunks touches against each other:
                    $fromStart === $previousHunkFromEnd
                    $toStart === $previousHunkTillEnd
                */

                $diffLineFromNumber = $fromStart;
                $diffLineToNumber   = $toStart;
                $expectHunkHeader   = false;

                continue;
            }

            if ('-' === $type) {
                if (isset($endOfLineTypes['-'])) {
                    throw new UnexpectedValueException(sprintf('Not expected from (\'-\'), already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
                }

                $diffLineFromNumber++;
            } elseif ('+' === $type) {
                if (isset($endOfLineTypes['+'])) {
                    throw new UnexpectedValueException(sprintf('Not expected to (\'+\'), already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
                }

                $diffLineToNumber++;
            } elseif (' ' === $type) {
                if (isset($endOfLineTypes['-'])) {
                    throw new UnexpectedValueException(sprintf('Not expected same (\' \'), \'-\' already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
                }

                if (isset($endOfLineTypes['+'])) {
                    throw new UnexpectedValueException(sprintf('Not expected same (\' \'), \'+\' already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
                }

                $diffLineFromNumber++;
                $diffLineToNumber++;
            } elseif ('\\' === $type) {
                if (!isset($lines[$lineNumber - 2])) {
                    throw new UnexpectedValueException(sprintf('Unexpected "\\ No newline at end of file", it must be preceded by \'+\' or \'-\' line. Line %d.', $lineNumber));
                }

                $previousType = $this->unifiedDiffAssertLinePrefix($lines[$lineNumber - 2], sprintf('Preceding line of "\\ No newline at end of file" of unexpected format. Line %d.', $lineNumber));

                if (isset($endOfLineTypes[$previousType])) {
                    throw new UnexpectedValueException(sprintf('Unexpected "\\ No newline at end of file", "%s" was already closed. Line %d.', $type, $lineNumber));
                }

                $endOfLineTypes[$previousType] = true;
                $diffClosed                    = count($endOfLineTypes) > 1;
            } else {
                // internal state error
                throw new RuntimeException(sprintf('Unexpected line type "%s" Line %d.', $type, $lineNumber));
            }

            $expectHunkHeader = $diffLineFromNumber === ($fromStart + $fromTillOffset) &&
                $diffLineToNumber === ($toStart + $toTillOffset);
        }

        if (
            $diffLineFromNumber !== ($fromStart + $fromTillOffset) &&
            $diffLineToNumber !== ($toStart + $toTillOffset)
        ) {
            throw new UnexpectedValueException(sprintf('Unexpected EOF, number of lines in hunk "from" (\'-\')) and "to" (\'+\') mismatched. Line %d.', $lineNumber));
        }

        if ($diffLineFromNumber !== ($fromStart + $fromTillOffset)) {
            throw new UnexpectedValueException(sprintf('Unexpected EOF, number of lines in hunk "from" (\'-\')) mismatched. Line %d.', $lineNumber));
        }

        if ($diffLineToNumber !== ($toStart + $toTillOffset)) {
            throw new UnexpectedValueException(sprintf('Unexpected EOF, number of lines in hunk "to" (\'+\')) mismatched. Line %d.', $lineNumber));
        }

        $this->addToAssertionCount(1);
    }

    /**
     * @return string '+', '-', '@', ' ' or '\'
     */
    private function unifiedDiffAssertLinePrefix(string $line, string $message): string
    {
        $this->unifiedDiffAssertStrLength($line, 2, $message); // 2: line type indicator ('+', '-', ' ' or '\') and a line break
        $firstChar = $line[0];

        if ('+' === $firstChar || '-' === $firstChar || '@' === $firstChar || ' ' === $firstChar) {
            return $firstChar;
        }

        if ("\\ No newline at end of file\n" === $line) {
            return '\\';
        }

        throw new UnexpectedValueException(sprintf('Expected line to start with \'@\', \'-\' or \'+\', got "%s". %s', $line, $message));
    }

    private function unifiedDiffAssertStrLength(string $line, int $min, string $message): void
    {
        $length = strlen($line);

        if ($length < $min) {
            throw new UnexpectedValueException(sprintf('Expected string length of minimal %d, got %d. %s', $min, $length, $message));
        }
    }

    /**
     * Assert valid unified diff header line.
     *
     * Samples:
     * - "+++ from1.txt\t2017-08-24 19:51:29.383985722 +0200"
     * - "+++ from1.txt"
     */
    private function unifiedDiffAssertHeaderLine(string $line, string $start, string $message): void
    {
        if (!str_starts_with($line, $start)) {
            throw new UnexpectedValueException(sprintf('Expected header line to start with "%s", got "%s". %s', $start . ' ', $line, $message));
        }

        // sample "+++ from1.txt\t2017-08-24 19:51:29.383985722 +0200\n"
        $match = preg_match(
            "/^([^\t]*)(?:[\t]([\\S].*[\\S]))?\n$/",
            substr($line, 4), // 4 === string length of "+++ " / "--- "
            $matches,
        );

        if (1 !== $match) {
            throw new UnexpectedValueException(sprintf('Header line does not match expected pattern, got "%s". %s', $line, $message));
        }

        // $file = $matches[1];

        if (count($matches) > 2) {
            $this->unifiedDiffAssertHeaderDate($matches[2], $message);
        }
    }

    private function unifiedDiffAssertHeaderDate(string $date, string $message): void
    {
        // sample "2017-08-24 19:51:29.383985722 +0200"
        $match = preg_match(
            '/^([\d]{4})-([01]?[\d])-([0123]?[\d])(:? [\d]{1,2}:[\d]{1,2}(?::[\d]{1,2}(:?\.[\d]+)?)?(?: ([\+\-][\d]{4}))?)?$/',
            $date,
            $matches,
        );

        if (1 !== $match) {
            throw new UnexpectedValueException(sprintf('Date of header line does not match expected pattern, got "%s". %s', $date, $message));
        }

        // [$full, $year, $month, $day, $time] = $matches;
    }

    /**
     * @return array{0: int, 1: int, 2: int, 3: int}
     */
    private function unifiedDiffAssertHunkHeader(string $line, string $message): array
    {
        if (1 !== preg_match('#^@@ -([\d]+)((?:,[\d]+)?) \+([\d]+)((?:,[\d]+)?) @@\n$#', $line, $matches)) {
            throw new UnexpectedValueException(
                sprintf(
                    'Hunk header line does not match expected pattern, got "%s". %s',
                    $line,
                    $message,
                ),
            );
        }

        return [
            (int) $matches[1],
            /** @phpstan-ignore empty.notAllowed */
            empty($matches[2]) ? 1 : (int) substr($matches[2], 1),
            (int) $matches[3],
            /** @phpstan-ignore empty.notAllowed */
            empty($matches[4]) ? 1 : (int) substr($matches[4], 1),
        ];
    }
}
