<?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\Http;

use App\Http\View;
use App\Tests\TestCase;
use ReflectionClass;
use ReflectionProperty;

/**
 * Smoke tests for the Twig-backed View. Phase 19 swapped the bare-PHP renderer
 * for Twig 3 — these confirm that the controllers' (name, $data) call shape
 * still produces HTML containing the markers each page is expected to carry.
 */
final class TwigViewTest extends TestCase
{
    private View $view;

    protected function setUp(): void
    {
        parent::setUp();
        $this->view = new View(__DIR__ . '/../../views');
    }

    private function makeUser(bool $isAdmin = true): object
    {
        $u = new \stdClass();
        $u->id          = 1;
        $u->email       = 'alice@example.com';
        $u->displayName = 'Alice';
        $u->isAdmin     = $isAdmin;
        return $u;
    }

    public function testHomeRendersForSignedInAdmin(): void
    {
        $html = $this->view->render('home', [
            'title'             => 'Sprint Planner',
            'csrfToken'         => 'tok',
            'currentUser'       => $this->makeUser(true),
            'schemaVersion'     => 3,
            'dbPath'            => '/tmp/x',
            'appEnv'            => 'production',
            'appVersion'        => '0.0.0-test',
            'appCreator'        => 'Test Creator',
            'oidcConfigured'    => false,
            'localAdminEnabled' => true,
            'authError'         => false,
            'sprintRows'        => [],
        ]);

        self::assertStringContainsString('<title>Sprint Planner</title>', $html);
        self::assertStringContainsString('Sprints', $html);
        self::assertStringContainsString('Alice', $html);     // displayName
        self::assertStringContainsString('admin', $html);     // admin badge
        self::assertStringContainsString('/auth/logout', $html);
        // Phase 19: vendored Alpine + htmx + sortable scripts must be linked.
        self::assertStringContainsString('/assets/js/vendor/alpine-csp.min.js', $html);
        self::assertStringContainsString('/assets/js/vendor/htmx.min.js', $html);
    }

    public function testHomeRendersDeletedSprintFlashWhenSet(): void
    {
        // R01-N26: the green "Sprint X was deleted" chip is now driven by
        // the `deletedSprintName` view variable (sourced from a one-shot
        // session flash, not from `?deleted=`). Pin that the chip appears
        // for any non-empty string and that the name is HTML-escaped.
        $html = $this->view->render('home', [
            'title'             => 'Sprint Planner',
            'csrfToken'         => 'tok',
            'currentUser'       => $this->makeUser(true),
            'schemaVersion'     => 3,
            'dbPath'            => '/tmp/x',
            'appEnv'            => 'production',
            'appVersion'        => '0.0.0-test',
            'appCreator'        => 'Test Creator',
            'oidcConfigured'    => false,
            'localAdminEnabled' => true,
            'authError'         => false,
            'deletedSprintName' => 'Sprint <Alpha>',
            'sprintRows'        => [],
        ]);

        self::assertStringContainsString('was deleted',         $html);
        self::assertStringContainsString('Sprint &lt;Alpha&gt;', $html);
        self::assertStringNotContainsString(
            '<b>Sprint <Alpha></b>',
            $html,
            'sprint name must be Twig-autoescaped — never rendered raw',
        );
    }

    public function testHomeOmitsDeletedSprintFlashWhenEmpty(): void
    {
        $html = $this->view->render('home', [
            'title'             => 'Sprint Planner',
            'csrfToken'         => 'tok',
            'currentUser'       => $this->makeUser(true),
            'schemaVersion'     => 3,
            'dbPath'            => '/tmp/x',
            'appEnv'            => 'production',
            'appVersion'        => '0.0.0-test',
            'appCreator'        => 'Test Creator',
            'oidcConfigured'    => false,
            'localAdminEnabled' => true,
            'authError'         => false,
            'deletedSprintName' => '',
            'sprintRows'        => [],
        ]);

        self::assertStringNotContainsString('was deleted', $html);
    }

    public function testHomeForAnonymousUserHidesRuntimePanel(): void
    {
        $html = $this->view->render('home', [
            'title'             => 'Sprint Planner',
            'csrfToken'         => 'tok',
            'currentUser'       => null,
            'schemaVersion'     => 3,
            'dbPath'            => '/var/data/app.sqlite',
            'appEnv'            => 'production',
            'appVersion'        => '0.0.0-test',
            'appCreator'        => 'Test Creator',
            'oidcConfigured'    => true,
            'localAdminEnabled' => true,
            'authError'         => false,
            'sprintRows'        => [],
        ]);

        self::assertStringContainsString('Sign in with Microsoft', $html);
        // R01-N02: the Runtime <details> panel must not leak app metadata,
        // dbPath, schema version, or OIDC/local-admin flags to anonymous
        // visitors. PHP version was removed from the panel entirely; the
        // app-version + creator strings now live there in its place.
        self::assertStringNotContainsString('Runtime', $html);
        self::assertStringNotContainsString('Schema version', $html);
        self::assertStringNotContainsString('/var/data/app.sqlite', $html);
        self::assertStringNotContainsString('0.0.0-test', $html);
        self::assertStringNotContainsString('Test Creator', $html);
        // R01-N31 falls out of the same gate: no /healthz hint either.
        self::assertStringNotContainsString('/healthz', $html);
    }

    public function testAuditRendersWithEmptyResults(): void
    {
        $html = $this->view->render('audit/index', [
            'title'       => 'Audit',
            'csrfToken'   => 'tok',
            'currentUser' => $this->makeUser(true),
            'filters'     => [
                'user_email'  => '',
                'action'      => '',
                'entity_type' => '',
                'entity_id'   => '',
                'from_date'   => '',
                'to_date'     => '',
            ],
            'page'        => 1,
            'pages'       => 1,
            'pageSize'    => 50,
            'total'       => 0,
            'rows'        => [],
            'actions'     => ['CREATE', 'UPDATE', 'DELETE'],
            'entityTypes' => ['worker', 'sprint'],
            'users'       => [],
        ]);

        self::assertStringContainsString('Audit log', $html);
        self::assertStringContainsString('No audit rows match', $html);
        // hx-boost wired on the filter form (Phase 19).
        self::assertStringContainsString('hx-boost="true"', $html);
    }

    public function testSprintsShowRendersTaskGridAndStatusFilter(): void
    {
        $sprint = (new ReflectionClass(\App\Domain\Sprint::class))->newInstanceWithoutConstructor();
        foreach ([
            'id' => 1, 'name' => 'S1',
            'startDate' => '2026-01-01', 'endDate' => '2026-01-15',
            'reserveFraction' => 0.2, 'isArchived' => false, 'createdAt' => '2026-01-01',
        ] as $k => $v) {
            (new ReflectionProperty($sprint, $k))->setValue($sprint, $v);
        }

        $weekClass = new ReflectionClass(\App\Domain\SprintWeek::class);
        $w = $weekClass->newInstanceWithoutConstructor();
        foreach ([
            'id' => 10, 'sprintId' => 1, 'isoWeek' => 1,
            'startDate' => '2026-01-05', 'sortOrder' => 1,
            'maxWorkingDays' => 5, 'activeDaysMask' => 31,
        ] as $k => $v) {
            (new ReflectionProperty($w, $k))->setValue($w, $v);
        }

        $swClass = new ReflectionClass(\App\Domain\SprintWorker::class);
        $sw = $swClass->newInstanceWithoutConstructor();
        foreach ([
            'id' => 100, 'sprintId' => 1, 'workerId' => 50, 'workerName' => 'Bob',
            'sortOrder' => 1, 'rtb' => 0.1,
        ] as $k => $v) {
            (new ReflectionProperty($sw, $k))->setValue($sw, $v);
        }

        $html = $this->view->render('sprints/show', [
            'title'             => 'S1',
            'csrfToken'         => 'tok',
            'currentUser'       => $this->makeUser(true),
            'sprint'            => $sprint,
            'weeks'             => [$w],
            'sprintWorkers'     => [$sw],
            'grid'              => [100 => [10 => 2.5]],
            'capacity'          => [100 => [
                'ressourcen'      => 2.5,
                'after_reserves'  => 2.0,
                'committed_prio1' => 0.0,
                'available'       => 2.0,
            ]],
            'tasks'             => [],
            'taskGrid'          => [],
            'statusGrid'        => [],
            'ownerChoices'      => [],
            'taskStatusEnabled' => true,
        ]);

        self::assertStringContainsString('data-sprint-root', $html);
        self::assertStringContainsString('data-sprint-id="1"', $html);
        self::assertStringContainsString('data-task-status-enabled="1"', $html);
        self::assertStringContainsString('Status', $html);    // status filter visible
        self::assertStringContainsString('No tasks yet', $html);
        // Phase 19: page-specific JS still loaded.
        self::assertStringContainsString('/assets/js/sprint-planner.js', $html);
        // R02-N02: + Add task clones a server-rendered <template> shell;
        // it must be present whenever the admin can hit the button. The
        // template re-uses _task_row.twig so JS-built rows can never drift
        // from the for-loop rows above. Pin a few load-bearing markers from
        // inside the template body — drift here breaks live task creation.
        self::assertStringContainsString('<template data-task-row-template>', $html);
        self::assertStringContainsString('data-owner-select',     $html);
        self::assertStringContainsString('data-prio-select',      $html);
        self::assertStringContainsString('data-task-menu-trigger', $html);
        self::assertStringContainsString('data-col="sw-100"',     $html);
    }

    public function testSprintsShowOmitsTaskRowTemplateForReadonlyUser(): void
    {
        // Non-admins cannot click "+ Add task", so the template (which
        // contains admin-only inputs) must not be served — keeps the rest
        // of the page lighter and rules out any chance of cloned-but-orphan
        // editor inputs being injected client-side.
        $sprint = (new ReflectionClass(\App\Domain\Sprint::class))->newInstanceWithoutConstructor();
        foreach ([
            'id' => 1, 'name' => 'S1',
            'startDate' => '2026-01-01', 'endDate' => '2026-01-15',
            'reserveFraction' => 0.2, 'isArchived' => false, 'createdAt' => '2026-01-01',
        ] as $k => $v) {
            (new ReflectionProperty($sprint, $k))->setValue($sprint, $v);
        }

        $weekClass = new ReflectionClass(\App\Domain\SprintWeek::class);
        $w = $weekClass->newInstanceWithoutConstructor();
        foreach ([
            'id' => 10, 'sprintId' => 1, 'isoWeek' => 1,
            'startDate' => '2026-01-05', 'sortOrder' => 1,
            'maxWorkingDays' => 5, 'activeDaysMask' => 31,
        ] as $k => $v) {
            (new ReflectionProperty($w, $k))->setValue($w, $v);
        }

        $swClass = new ReflectionClass(\App\Domain\SprintWorker::class);
        $sw = $swClass->newInstanceWithoutConstructor();
        foreach ([
            'id' => 100, 'sprintId' => 1, 'workerId' => 50, 'workerName' => 'Bob',
            'sortOrder' => 1, 'rtb' => 0.1,
        ] as $k => $v) {
            (new ReflectionProperty($sw, $k))->setValue($sw, $v);
        }

        $html = $this->view->render('sprints/show', [
            'title'             => 'S1',
            'csrfToken'         => 'tok',
            'currentUser'       => $this->makeUser(false),
            'sprint'            => $sprint,
            'weeks'             => [$w],
            'sprintWorkers'     => [$sw],
            'grid'              => [100 => [10 => 2.5]],
            'capacity'          => [100 => [
                'ressourcen'      => 2.5,
                'after_reserves'  => 2.0,
                'committed_prio1' => 0.0,
                'available'       => 2.0,
            ]],
            'tasks'             => [],
            'taskGrid'          => [],
            'statusGrid'        => [],
            'ownerChoices'      => [],
            'taskStatusEnabled' => true,
        ]);

        self::assertStringNotContainsString('<template data-task-row-template>', $html);
        self::assertStringNotContainsString('+ Add task', $html);
    }
}
