<?php

declare(strict_types=1);

// Load the API plugin's autoloader so its controller classes are available
require_once '/Users/rhuk/Projects/grav/grav-plugin-api/vendor/autoload.php';

use Codeception\Util\Fixtures;
use Grav\Common\Grav;
use Grav\Common\Language\Language;
use Grav\Common\Page\Pages;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Plugin\Api\Controllers\PagesController;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;

/**
 * Integration tests pinning multilingual page resolution for GET/PATCH/DELETE.
 *
 * Regression coverage for getgrav/grav-plugin-api#6: a PATCH carrying
 * ?lang=<secondary> used to clobber the default-language file (and a DELETE
 * 404'd) because Grav builds and caches the pages index for whichever language
 * is active at init time. The controller set the active language but never
 * forced the index to rebuild, so find()/save() resolved to the wrong
 * translation file. The fix rebuilds pages whenever applyLanguage() changes the
 * active language.
 */
class PagesControllerLanguageTest extends \PHPUnit\Framework\TestCase
{
    protected Grav $grav;
    protected Pages $pages;
    protected string $tempDir;

    protected function setUp(): void
    {
        parent::setUp();

        $grav = Fixtures::get('grav');
        $this->grav = $grav();

        $this->tempDir = sys_get_temp_dir() . '/grav_api_lang_test_' . uniqid();
        @mkdir($this->tempDir . '/pages', 0775, true);
        @mkdir($this->tempDir . '/cache', 0775, true);

        /** @var UniformResourceLocator $locator */
        $locator = $this->grav['locator'];
        $locator->addPath('page', '', $this->tempDir . '/pages', false);
        $locator->addPath('cache', '', $this->tempDir . '/cache', false);

        // API config
        $this->grav['config']->set('plugins.api.route', '/api');
        $this->grav['config']->set('plugins.api.version_prefix', 'v1');

        // Turn this install into a multilingual site: en (default) + fr, with
        // the default language stored in a suffix-less file (default.md).
        $this->grav['config']->set('system.languages.supported', ['en', 'fr']);
        $this->grav['config']->set('system.languages.default_lang', 'en');
        $this->grav['config']->set('system.languages.include_default_lang', false);
        // Disable the on-disk pages cache so each rebuild reads the fixtures we
        // just wrote rather than a stale per-language snapshot.
        $this->grav['config']->set('system.cache.enabled', false);

        // The Language service reads system.languages.supported in its
        // constructor, so rebuild it now that the config is multilingual.
        // Pimple refuses to redefine a service that was already used, and an
        // unset alone leaves no definition behind, so register a fresh one.
        unset($this->grav['language']);
        $this->grav['language'] = new Language($this->grav);
        /** @var Language $language */
        $language = $this->grav['language'];
        self::assertTrue($language->enabled(), 'Multi-language should be enabled for these tests');

        $this->pages = $this->grav['pages'];
    }

    protected function tearDown(): void
    {
        $this->rmrf($this->tempDir);
        parent::tearDown();
    }

    public function testPatchWithLangUpdatesTheTargetTranslationNotTheDefault(): void
    {
        $this->createMultilangPage('contact', [
            'en' => "title: Contact\n",
            'fr' => "title: Contactez\n",
        ], [
            'en' => 'English body',
            'fr' => 'Corps francais',
        ]);

        $controller = $this->createPagesController();
        $request = $this->makeRequest(
            'PATCH',
            '/api/v1/pages/contact',
            ['content' => 'Nouveau corps francais'],
            ['route' => 'contact'],
            ['lang' => 'fr'],
        );

        $response = $controller->update($request);
        self::assertSame(200, $response->getStatusCode());

        $frFile = $this->tempDir . '/pages/contact/default.fr.md';
        $enFile = $this->tempDir . '/pages/contact/default.md';

        self::assertStringContainsString('Nouveau corps francais', file_get_contents($frFile), 'French file should be updated');
        self::assertStringContainsString('English body', file_get_contents($enFile), 'Default (English) file must be untouched');
        self::assertStringNotContainsString('Nouveau corps francais', file_get_contents($enFile), 'Default file must not receive the French payload');
    }

    public function testPatchWithoutLangUpdatesTheDefaultTranslation(): void
    {
        $this->createMultilangPage('about', [
            'en' => "title: About\n",
            'fr' => "title: A propos\n",
        ], [
            'en' => 'English body',
            'fr' => 'Corps francais',
        ]);

        $controller = $this->createPagesController();
        $request = $this->makeRequest(
            'PATCH',
            '/api/v1/pages/about',
            ['content' => 'Updated english body'],
            ['route' => 'about'],
        );

        $response = $controller->update($request);
        self::assertSame(200, $response->getStatusCode());

        $enFile = $this->tempDir . '/pages/about/default.md';
        $frFile = $this->tempDir . '/pages/about/default.fr.md';

        self::assertStringContainsString('Updated english body', file_get_contents($enFile), 'Default file should be updated');
        self::assertStringContainsString('Corps francais', file_get_contents($frFile), 'French file must be untouched');
    }

    public function testDeleteWithLangRemovesOnlyThatTranslation(): void
    {
        $this->createMultilangPage('news', [
            'en' => "title: News\n",
            'fr' => "title: Nouvelles\n",
        ], [
            'en' => 'English body',
            'fr' => 'Corps francais',
        ]);

        $controller = $this->createPagesController();
        $request = $this->makeRequest(
            'DELETE',
            '/api/v1/pages/news',
            [],
            ['route' => 'news'],
            ['lang' => 'fr'],
        );

        $response = $controller->delete($request);
        self::assertSame(204, $response->getStatusCode());

        self::assertFileDoesNotExist($this->tempDir . '/pages/news/default.fr.md', 'French translation should be deleted');
        self::assertFileExists($this->tempDir . '/pages/news/default.md', 'Default translation must survive');
    }

    /**
     * getgrav/grav-plugin-admin2#199: with the default language stored without
     * an extension, a page whose only file is `mission.fr.md` comes back from
     * Grav as an empty folder page when asked for in the default language. The
     * editor took that for the default-language file, listed no translations
     * and sent DELETE ?lang=en, which 404'd.
     */
    public function testShowFallsBackToTheTranslationOnDiskWhenTheRequestedLanguageHasNoFile(): void
    {
        $this->createPageFile('mission', 'mission.fr.md', "title: Mission secrete\n", 'Corps francais');

        $controller = $this->createPagesController();
        $request = $this->makeRequest('GET', '/api/v1/pages/mission', [], ['route' => 'mission'], ['lang' => 'en', 'translations' => 'true']);

        $data = json_decode((string) $controller->show($request)->getBody(), true)['data'];

        self::assertSame('fr', $data['language'], 'The page should come back in the language it has a file for');
        self::assertSame('Mission secrete', $data['title']);
        self::assertSame(['fr'], array_keys($data['translated_languages']));
        self::assertSame(['en'], $data['untranslated_languages']);
    }

    public function testDeleteWithTheOnlyExistingTranslationRemovesThePage(): void
    {
        $this->createPageFile('mission', 'mission.fr.md', "title: Mission secrete\n", 'Corps francais');

        $controller = $this->createPagesController();
        $request = $this->makeRequest('DELETE', '/api/v1/pages/mission', [], ['route' => 'mission'], ['lang' => 'fr', 'children' => 'true']);

        self::assertSame(204, $controller->delete($request)->getStatusCode());
        self::assertDirectoryDoesNotExist($this->tempDir . '/pages/mission');
    }

    public function testDeleteInALanguageThePageHasNoFileForLeavesTheOtherTranslation(): void
    {
        $this->createPageFile('mission', 'mission.fr.md', "title: Mission secrete\n", 'Corps francais');

        $controller = $this->createPagesController();
        $request = $this->makeRequest('DELETE', '/api/v1/pages/mission', [], ['route' => 'mission'], ['lang' => 'en', 'children' => 'true']);

        try {
            $controller->delete($request);
            self::fail('Deleting a language the page has no file for should be refused');
        } catch (\Grav\Plugin\Api\Exceptions\NotFoundException) {
        }
        self::assertFileExists($this->tempDir . '/pages/mission/mission.fr.md');
    }

    /**
     * Saving the empty folder page wrote nothing and answered 200 (or 500 for a
     * title-only change), so the editor lost the edit without saying so.
     */
    public function testPatchInALanguageThePageHasNoFileForIsRefused(): void
    {
        $this->createPageFile('mission', 'mission.fr.md', "title: Mission secrete\n", 'Corps francais');

        $controller = $this->createPagesController();
        $request = $this->makeRequest('PATCH', '/api/v1/pages/mission', ['content' => 'English body'], ['route' => 'mission'], ['lang' => 'en']);

        $this->expectException(\Grav\Plugin\Api\Exceptions\ValidationException::class);
        $controller->update($request);
    }

    /**
     * A PATCH without ?lang on a page whose only file is a translation acted on
     * the empty folder page: its ETag never matched the one GET returned, and
     * the save had no file behind it (500, later a 200 that wrote nothing).
     */
    public function testPatchWithoutLangUpdatesTheOnlyTranslationOnDisk(): void
    {
        $this->createPageFile('mission', 'mission.fr.md', "title: Mission secrete\n", 'Corps francais');

        $controller = $this->createPagesController();
        $show = $controller->show($this->makeRequest('GET', '/api/v1/pages/mission', [], ['route' => 'mission']));
        $etag = $show->getHeaderLine('ETag');
        self::assertNotSame('', $etag);

        // GET leaves the translation's language active; a new request starts
        // with no active language, as it does live
        unset($this->grav['language']);
        $this->grav['language'] = new Language($this->grav);
        $this->pages->reset();
        $this->pages->init();

        $request = $this->makeRequest('PATCH', '/api/v1/pages/mission', ['content' => 'Nouveau corps'], ['route' => 'mission'])
            ->withHeader('If-Match', $etag);

        self::assertSame(200, $controller->update($request)->getStatusCode());
        self::assertStringContainsString('Nouveau corps', file_get_contents($this->tempDir . '/pages/mission/mission.fr.md'));
        self::assertSame(['mission.fr.md'], array_values(array_diff(scandir($this->tempDir . '/pages/mission'), ['.', '..'])));
    }

    // =========================================================================
    // Helper methods
    // =========================================================================

    private function createPagesController(): PagesController
    {
        return new PagesController($this->grav, $this->grav['config']);
    }

    /**
     * Write a page folder with a suffix-less default.md for the default
     * language and default.<lang>.md for every secondary language.
     *
     * @param array<string, string> $headers   lang => frontmatter body (YAML)
     * @param array<string, string> $contents  lang => markdown body
     */
    private function createMultilangPage(string $slug, array $headers, array $contents): void
    {
        $dir = $this->tempDir . '/pages/' . $slug;
        @mkdir($dir, 0775, true);

        $default = $this->grav['language']->getDefault();
        foreach ($headers as $lang => $frontmatter) {
            $name = $lang === $default ? 'default.md' : "default.{$lang}.md";
            file_put_contents($dir . '/' . $name, "---\n{$frontmatter}---\n" . ($contents[$lang] ?? ''));
        }

        $this->pages->reset();
        $this->pages->init();
    }

    /** Write a single page file, for a page that has only one translation. */
    private function createPageFile(string $slug, string $filename, string $frontmatter, string $content): void
    {
        $this->grav['config']->set('system.languages.include_default_lang_file_extension', false);

        $dir = $this->tempDir . '/pages/' . $slug;
        @mkdir($dir, 0775, true);
        file_put_contents($dir . '/' . $filename, "---\n{$frontmatter}---\n" . $content);

        $this->pages->reset();
        $this->pages->init();
    }

    private function makeRequest(
        string $method,
        string $path,
        array $body = [],
        array $routeParams = [],
        array $query = [],
    ): \Psr\Http\Message\ServerRequestInterface {
        $superAdmin = $this->createSuperAdmin();

        return new class ($method, $path, $body, $routeParams, $query, $superAdmin) implements \Psr\Http\Message\ServerRequestInterface {
            private array $attributes;

            public function __construct(
                private readonly string $method,
                private readonly string $path,
                private readonly array $body,
                array $routeParams,
                private readonly array $query,
                object $user,
            ) {
                $this->attributes = [
                    'api_user' => $user,
                    'json_body' => $body,
                    'route_params' => $routeParams,
                ];
            }

            public function getMethod(): string { return $this->method; }
            public function getQueryParams(): array { return $this->query; }
            public function getServerParams(): array { return []; }
            public function getAttribute(string $name, mixed $default = null): mixed { return $this->attributes[$name] ?? $default; }
            public function getHeaderLine(string $name): string { return ''; }
            public function getHeader(string $name): array { return []; }
            public function hasHeader(string $name): bool { return false; }
            public function getHeaders(): array { return []; }
            public function getParsedBody(): mixed { return $this->body; }
            public function getUploadedFiles(): array { return []; }

            public function withAttribute(string $name, mixed $value): static {
                $clone = clone $this;
                $clone->attributes[$name] = $value;
                return $clone;
            }

            // Stubs for remaining PSR-7 methods
            public function getUri(): \Psr\Http\Message\UriInterface {
                $path = $this->path;
                return new class($path) implements \Psr\Http\Message\UriInterface {
                    public function __construct(private string $p) {}
                    public function getScheme(): string { return 'https'; }
                    public function getAuthority(): string { return ''; }
                    public function getUserInfo(): string { return ''; }
                    public function getHost(): string { return 'localhost'; }
                    public function getPort(): ?int { return null; }
                    public function getPath(): string { return $this->p; }
                    public function getQuery(): string { return ''; }
                    public function getFragment(): string { return ''; }
                    public function withScheme(string $scheme): static { return clone $this; }
                    public function withUserInfo(string $user, ?string $password = null): static { return clone $this; }
                    public function withHost(string $host): static { return clone $this; }
                    public function withPort(?int $port): static { return clone $this; }
                    public function withPath(string $path): static { return clone $this; }
                    public function withQuery(string $query): static { return clone $this; }
                    public function withFragment(string $fragment): static { return clone $this; }
                    public function __toString(): string { return $this->p; }
                };
            }
            public function getBody(): \Psr\Http\Message\StreamInterface {
                $c = json_encode($this->body);
                return new class($c) implements \Psr\Http\Message\StreamInterface {
                    public function __construct(private string $c) {}
                    public function __toString(): string { return $this->c; }
                    public function close(): void {}
                    public function detach() { return null; }
                    public function getSize(): ?int { return strlen($this->c); }
                    public function tell(): int { return 0; }
                    public function eof(): bool { return true; }
                    public function isSeekable(): bool { return false; }
                    public function seek(int $offset, int $whence = SEEK_SET): void {}
                    public function rewind(): void {}
                    public function isWritable(): bool { return false; }
                    public function write(string $string): int { return 0; }
                    public function isReadable(): bool { return true; }
                    public function read(int $length): string { return $this->c; }
                    public function getContents(): string { return $this->c; }
                    public function getMetadata(?string $key = null): mixed { return null; }
                };
            }
            public function getRequestTarget(): string { return $this->path; }
            public function withRequestTarget(string $requestTarget): static { return clone $this; }
            public function withMethod(string $method): static { return clone $this; }
            public function withUri(\Psr\Http\Message\UriInterface $uri, bool $preserveHost = false): static { return clone $this; }
            public function getProtocolVersion(): string { return '1.1'; }
            public function withProtocolVersion(string $version): static { return clone $this; }
            public function withHeader(string $name, $value): static { return clone $this; }
            public function withAddedHeader(string $name, $value): static { return clone $this; }
            public function withoutHeader(string $name): static { return clone $this; }
            public function withBody(\Psr\Http\Message\StreamInterface $body): static { return clone $this; }
            public function getCookieParams(): array { return []; }
            public function withCookieParams(array $cookies): static { return clone $this; }
            public function withQueryParams(array $query): static { return clone $this; }
            public function withParsedBody($data): static { return clone $this; }
            public function getAttributes(): array { return $this->attributes; }
            public function withUploadedFiles(array $uploadedFiles): static { return clone $this; }
            public function withoutAttribute(string $name): static {
                $clone = clone $this;
                unset($clone->attributes[$name]);
                return $clone;
            }
        };
    }

    private function createSuperAdmin(): UserInterface
    {
        /** @var \Grav\Common\User\Interfaces\UserCollectionInterface $accounts */
        $accounts = $this->grav['accounts'];
        $user = $accounts->load('admin');

        if (!$user->exists()) {
            $user->set('email', 'admin@test.com');
            $user->set('fullname', 'Test Admin');
            $user->set('state', 'enabled');
            $user->set('access', [
                'admin' => ['super' => true, 'login' => true],
                'api' => ['access' => true, 'pages' => ['read' => true, 'write' => true]],
            ]);
            $user->save();
        }

        return $user;
    }

    private function rmrf(string $dir): void
    {
        if (!is_dir($dir)) return;
        foreach (scandir($dir) as $item) {
            if ($item === '.' || $item === '..') continue;
            $path = $dir . '/' . $item;
            is_dir($path) ? $this->rmrf($path) : unlink($path);
        }
        rmdir($dir);
    }
}
