<?php

declare(strict_types=1);

namespace Srp\Tests;

use PHPUnit\Framework\TestCase;

/**
 * Unit test for the append-only click-log writer in
 * redirect/_meetups/clicks.php (meetup_append_click_record + helpers).
 *
 * clicks.php runs a procedural write at include time and derives its target
 * path from __DIR__, so it cannot be required directly without writing into the
 * project's statistics/temp. To exercise the (global, function_exists-guarded)
 * functions in isolation we copy the file two levels deep into a throwaway
 * scratch tree and require it there: the include-time write lands harmlessly at
 * {scratch}/statistics/temp, and tearDownAfterClass removes the whole tree.
 */
final class MeetupClickLogTest extends TestCase
{
    private static string $scratch = '';

    public static function setUpBeforeClass(): void
    {
        self::$scratch = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'srp_clicklog_' . bin2hex(random_bytes(6));
        $copyDir = self::$scratch . '/a/b';
        mkdir($copyDir, 0700, true);
        copy(__DIR__ . '/../redirect/_meetups/clicks.php', $copyDir . '/clicks.php');

        // Neutralise the ambient vars the procedural section reads so the
        // include-time write is harmless (and contained in the scratch tree).
        $click_id = $country_code = $user_agent = $ip_address = $user_lp = '';
        require $copyDir . '/clicks.php'; // defines \meetup_append_click_record() etc.
    }

    public static function tearDownAfterClass(): void
    {
        self::removeTree(self::$scratch);
    }

    private static function removeTree(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }

        foreach (scandir($dir) ?: [] as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }

            $path = $dir . DIRECTORY_SEPARATOR . $entry;
            is_dir($path) ? self::removeTree($path) : @unlink($path);
        }

        @rmdir($dir);
    }

    /** @return array<string, string> */
    private function record(string $clickId): array
    {
        return [
            'click_id' => $clickId,
            'country_code' => 'us',
            'user_agent' => 'web',
            'ip_address' => '1.2.3.4',
            'info' => 'lospollos',
            'time' => '2026-06-25',
        ];
    }

    private function logFile(string $name): string
    {
        return self::$scratch . DIRECTORY_SEPARATOR . $name;
    }

    public function testFreshFileGetsContiguousIdsAndStableKeyOrder(): void
    {
        $file = $this->logFile('fresh.json');
        for ($i = 1; $i <= 5; $i++) {
            \meetup_append_click_record($file, $this->record("c$i"));
        }

        $raw = (string) file_get_contents($file);
        $rows = json_decode($raw, true);

        self::assertIsArray($rows);
        self::assertCount(5, $rows);
        self::assertSame([1, 2, 3, 4, 5], array_column($rows, 'id'));
        self::assertSame(
            ['id', 'click_id', 'country_code', 'user_agent', 'ip_address', 'info', 'time'],
            array_keys($rows[0]),
        );
        // Single-line array keeps the streaming reader (pcReadJsonPage) compatible.
        self::assertStringNotContainsString("\n", $raw);
    }

    public function testAppendsToLegacyArrayContinuingId(): void
    {
        $file = $this->logFile('legacy.json');
        file_put_contents(
            $file,
            '[{"id":1,"click_id":"a","country_code":"id","user_agent":"wap","ip_address":"9.9.9.9","info":"x","time":"t"}]',
        );
        \meetup_append_click_record($file, $this->record('c2'));

        self::assertSame([1, 2], array_column((array) json_decode((string) file_get_contents($file), true), 'id'));
    }

    public function testEmptyArrayGetsFirstElement(): void
    {
        $file = $this->logFile('empty.json');
        file_put_contents($file, '[]');
        \meetup_append_click_record($file, $this->record('c1'));

        $rows = json_decode((string) file_get_contents($file), true);
        self::assertIsArray($rows);
        self::assertCount(1, $rows);
        self::assertSame(1, $rows[0]['id']);
    }

    public function testTrailingNewlineTolerated(): void
    {
        $file = $this->logFile('nl.json');
        file_put_contents($file, "[{\"id\":7,\"click_id\":\"z\"}]\n");
        \meetup_append_click_record($file, $this->record('c8'));

        self::assertSame([7, 8], array_column((array) json_decode((string) file_get_contents($file), true), 'id'));
    }

    public function testCorruptFileIsRebuiltToValidArray(): void
    {
        $file = $this->logFile('corrupt.json');
        file_put_contents($file, 'not-json-at-all');
        \meetup_append_click_record($file, $this->record('cx'));

        $rows = json_decode((string) file_get_contents($file), true);
        self::assertIsArray($rows);
        self::assertCount(1, $rows);
        self::assertSame(1, $rows[0]['id']);
    }

    public function testScaleStaysValidAndContiguous(): void
    {
        $file = $this->logFile('scale.json');
        for ($i = 1; $i <= 1000; $i++) {
            \meetup_append_click_record($file, $this->record("c$i"));
        }

        $rows = json_decode((string) file_get_contents($file), true);
        self::assertIsArray($rows);
        self::assertCount(1000, $rows);
        self::assertSame(1, $rows[0]['id']);
        self::assertSame(1000, $rows[999]['id']);
    }
}
