<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Tests\Controllers;

use App\Controllers\ImportController;
use App\Tests\TestCase;
use ReflectionMethod;

/**
 * Phase 20 — pure-static guards on ImportController. Mirrors the
 * UserControllerTest pattern: exercise the bits that do not need PDO or
 * session wiring.
 */
final class ImportControllerTest extends TestCase
{
    public function testLooksLikeXlsxAcceptsRealZipHeader(): void
    {
        $tmp = tempnam(sys_get_temp_dir(), 'sptest');
        file_put_contents($tmp, "PK\x03\x04rest of the file");
        try {
            $this->assertTrue(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
            $this->assertFalse(self::call('looksLikeXlsx', 'workbook.txt',  $tmp), 'wrong extension');
        } finally {
            unlink($tmp);
        }
    }

    public function testLooksLikeXlsxRejectsNonZip(): void
    {
        $tmp = tempnam(sys_get_temp_dir(), 'sptest');
        file_put_contents($tmp, 'not a zip');
        try {
            $this->assertFalse(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
        } finally {
            unlink($tmp);
        }
    }

    public function testLooksLikeXlsxRejectsTooShortFile(): void
    {
        $tmp = tempnam(sys_get_temp_dir(), 'sptest');
        file_put_contents($tmp, 'PK');
        try {
            $this->assertFalse(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
        } finally {
            unlink($tmp);
        }
    }

    public function testUploadErrorCodeMapping(): void
    {
        $this->assertSame('too_big', self::call('uploadErrorCode', UPLOAD_ERR_INI_SIZE));
        $this->assertSame('too_big', self::call('uploadErrorCode', UPLOAD_ERR_FORM_SIZE));
        $this->assertSame('partial', self::call('uploadErrorCode', UPLOAD_ERR_PARTIAL));
        $this->assertSame('no_file', self::call('uploadErrorCode', UPLOAD_ERR_NO_FILE));
        $this->assertSame('server',  self::call('uploadErrorCode', UPLOAD_ERR_NO_TMP_DIR));
        $this->assertSame('server',  self::call('uploadErrorCode', UPLOAD_ERR_CANT_WRITE));
        $this->assertSame('unknown', self::call('uploadErrorCode', 9999));
    }

    // ---------------------------------------------------------------------
    // R01-N14: per-token serialised-payload cap + abandoned-token audit
    // ---------------------------------------------------------------------

    public function testEncodedPayloadBytesIsZeroForEmpty(): void
    {
        $this->assertSame(2, ImportController::encodedPayloadBytes([]), 'JSON for [] is two bytes');
    }

    public function testEncodedPayloadBytesGrowsWithContent(): void
    {
        $small = ImportController::encodedPayloadBytes([['sheetName' => 'A', 'tasks' => []]]);
        $big   = ImportController::encodedPayloadBytes([
            ['sheetName' => 'A', 'tasks' => array_fill(0, 100, ['title' => str_repeat('x', 50)])],
        ]);
        $this->assertGreaterThan($small, $big);
    }

    public function testEncodedPayloadBytesMatchesJsonStrlen(): void
    {
        // The cap is "JSON byte length"; the helper must agree with what
        // a manual json_encode() of the same payload reports — otherwise
        // the contract documented in REVIEW_01.md drifts.
        $sheetsArr = [
            ['sheetName' => 'Sprint A', 'tasks' => [['title' => 'Älphå', 'days' => 1.5]]],
            ['sheetName' => 'Sprint B', 'tasks' => []],
        ];
        $expected = strlen((string) json_encode(
            $sheetsArr,
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
        ));
        $this->assertSame($expected, ImportController::encodedPayloadBytes($sheetsArr));
    }

    public function testCapConstantMatchesReviewN14(): void
    {
        // Pin the cap at exactly 2 MiB. The number is documented in
        // REVIEW_01.md and the upload-error message; if it changes, both
        // pieces of documentation need to be edited too.
        $this->assertSame(
            2 * 1024 * 1024,
            ImportController::MAX_SESSION_PAYLOAD_BYTES,
            'cap drift: bumping this constant requires updating REVIEW_01.md + import_upload.twig',
        );
    }

    public function testEncodedPayloadCanCrossTheCap(): void
    {
        // Sanity: a deliberately-large payload exceeds the cap. We feed
        // the helper a payload just past the threshold to prove the
        // arithmetic agrees in the direction the controller relies on.
        $blob   = str_repeat('A', ImportController::MAX_SESSION_PAYLOAD_BYTES);
        $sheets = [['sheetName' => 'big', 'data' => $blob]];
        $this->assertGreaterThan(
            ImportController::MAX_SESSION_PAYLOAD_BYTES,
            ImportController::encodedPayloadBytes($sheets),
        );
    }

    public function testAbandonedAuditPayloadShapeAndContents(): void
    {
        $entry = [
            'created_at'    => 1_700_000_000,  // 2023-11-14T22:13:20Z
            'file_name'     => 'planning.xlsx',
            'payload_bytes' => 1234,
            'sheets'        => [
                ['sheetName' => 'A'],
                ['sheetName' => 'B'],
                ['sheetName' => 'C'],
            ],
        ];
        $now = 1_700_000_300; // 5 minutes later

        $payload = ImportController::abandonedAuditPayload($entry, $now);

        $this->assertSame('planning.xlsx',         $payload['file_name']);
        $this->assertSame(3,                       $payload['sheet_count']);
        $this->assertSame(1234,                    $payload['payload_bytes']);
        $this->assertSame(300,                     $payload['age_seconds']);
        $this->assertSame('2023-11-14T22:13:20Z',  $payload['created_at']);
    }

    public function testAbandonedAuditPayloadHandlesMissingFields(): void
    {
        // Old or malformed session entries (e.g. from a session created
        // before the payload_bytes field landed): the helper must not
        // throw and must fall back to safe defaults rather than leaking
        // PHP nulls into an audit row.
        $payload = ImportController::abandonedAuditPayload([], 1_700_000_000);
        $this->assertSame('',  $payload['file_name']);
        $this->assertSame(0,   $payload['sheet_count']);
        $this->assertSame(0,   $payload['payload_bytes']);
        $this->assertSame(0,   $payload['age_seconds'], 'created_at=0 → age clamped to 0, not negative');
        $this->assertSame('',  $payload['created_at'],   'no spoofed timestamp when created_at is missing');
    }

    public function testAbandonedAuditAgeNeverNegative(): void
    {
        // Defensive: clock skew or a session entry from "the future"
        // (e.g. a system clock that just rolled back) must not produce
        // a negative age — the audit row would render badly in /audit.
        $payload = ImportController::abandonedAuditPayload(
            ['created_at' => 2_000_000_000, 'sheets' => []],
            1_999_999_900,
        );
        $this->assertSame(0, $payload['age_seconds']);
    }

    /**
     * Reflectively call a private static helper on ImportController so we
     * don't need to expand its public surface for testability.
     */
    private static function call(string $method, mixed ...$args): mixed
    {
        $r = new ReflectionMethod(ImportController::class, $method);
        $r->setAccessible(true);
        return $r->invoke(null, ...$args);
    }
}
