<?php

namespace Acms\Services\Template;

use Acms\Services\Common\HookFactory;
use Acms\Services\Template\Twig\AcmsConfigExtension;
use Acms\Services\Facades\LocalStorage;
use Acms\Services\Template\Twig\AcmsExtension;
use Acms\Services\Template\Twig\CommentedIncludeExtension;
use Acms\Services\Template\Twig\CustomLoader;
use Acms\Services\Template\Twig\TokenParser\MarkdownTokenParser;
use LogicException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Twig\Environment;
use Twig\Extension\AbstractExtension;
use Twig\Extension\DebugExtension;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFilter;
use Twig\TwigFunction;

class Twig
{
    /**
     * @var \Twig\Environment|null
     */
    protected $twig;

    /**
     * @var \Acms\Services\Template\Twig\CustomLoader|null
     */
    protected $loader;

    /**
     * @var string
     */
    protected $path = '';

    /**
     * @var string
     */
    protected $theme = '';

    /**
     * 拡張側から登録されたテンプレートディレクトリ (Environment 構築時に flush)。
     *
     * @var array<string, array<int, string>> namespace => list<path>
     */
    private array $pendingTemplatePaths = [];

    /**
     * 拡張側から登録された Filter (Environment 構築時に flush)。
     * Filter 名をキーにして同名の二重登録を最後の登録で束ねる。
     * extendsTwig Hook は Environment 再構築のたびに再発火し得るため、
     * 同名 Filter が複数積まれて addFilter が LogicException を投げるのを防ぐ。
     *
     * @var array<string, array{name: string, callable: callable, options: array<string, mixed>}>
     */
    private array $pendingFilters = [];

    /**
     * 拡張側から登録された Function (Environment 構築時に flush)。
     * Function 名をキーにして同名の二重登録を最後の登録で束ねる ($pendingFilters と同様)。
     *
     * @var array<string, array{name: string, callable: callable, options: array<string, mixed>}>
     */
    private array $pendingFunctions = [];

    /**
     * 拡張側から登録された Extension (Environment 構築時に flush)。
     * 同型クラスの二重登録は最後の登録を採用する。
     *
     * @var array<class-string, AbstractExtension>
     */
    private array $pendingExtensions = [];

    /**
     * pending リストに変更があり次回 render 時に Environment を再構築すべきか。
     *
     * Twig\Environment は最初の render で ExtensionSet が initialized になり、
     * それ以降は addFilter / addFunction / addExtension が例外で弾かれる。
     * このフラグが立っているときは render の冒頭で bootstrap をやり直す。
     */
    private bool $needsRebuild = false;

    /**
     * twigテンプレートをロード
     *
     * @param string $path
     * @param string $theme
     * @return void
     * @throws LogicException
     */
    public function load(string $path, string $theme): void
    {
        $this->path = $path;
        $this->theme = $theme;
        $this->bootstrapEnvironment($theme);
    }

    /**
     * 拡張側がテンプレートディレクトリを名前空間付きで登録するための窓口。
     * Environment 構築前なら pending に積み、構築済みなら即時反映する。
     *
     * @param string $dir
     * @param string|null $namespace null の場合は MAIN_NAMESPACE
     * @return void
     */
    public function addTemplatePath(string $dir, ?string $namespace = null): void
    {
        $namespace = $namespace ?? FilesystemLoader::MAIN_NAMESPACE;
        $registered = $this->pendingTemplatePaths[$namespace] ?? [];
        if (in_array($dir, $registered, true)) {
            return;
        }
        $this->pendingTemplatePaths[$namespace][] = $dir;
        if ($this->loader !== null) {
            $this->loader->addPath($dir, $namespace);
        }
    }

    /**
     * 拡張を追加 (既存 API)。
     *
     * 互換のため Environment 構築前 (load 未実行) の呼び出しも受け付け、
     * pending に積んで構築時に attach する。
     *
     * @param AbstractExtension $extension
     * @return void
     * @throws LogicException
     */
    public function addExtension(AbstractExtension $extension): void
    {
        $this->registerExtension($extension);
    }

    /**
     * Functionを追加 (既存 API)。
     *
     * 互換のため Environment 構築前の呼び出しも受け付け、pending に積む。
     *
     * @param string $name
     * @param callable $function
     * @return void
     * @throws LogicException
     */
    public function addFunction(string $name, callable $function): void
    {
        $this->registerFunction($name, $function);
    }

    /**
     * 拡張側が register する用の Filter 登録窓口。
     *
     * Environment 未構築なら pending に積み、構築済みなら即時 addFilter する。
     *
     * @param string $name
     * @param callable $callable
     * @param array<string, mixed> $options Twig\TwigFilter のコンストラクタ第 3 引数と同じ
     * @return void
     */
    public function registerFilter(string $name, callable $callable, array $options = []): void
    {
        $this->pendingFilters[$name] = [
            'name' => $name,
            'callable' => $callable,
            'options' => $options,
        ];
        if ($this->twig !== null) {
            $this->needsRebuild = true;
        }
    }

    /**
     * 拡張側が register する用の Function 登録窓口。
     *
     * @param string $name
     * @param callable $callable
     * @param array<string, mixed> $options Twig\TwigFunction のコンストラクタ第 3 引数と同じ
     * @return void
     */
    public function registerFunction(string $name, callable $callable, array $options = []): void
    {
        $this->pendingFunctions[$name] = [
            'name' => $name,
            'callable' => $callable,
            'options' => $options,
        ];
        if ($this->twig !== null) {
            $this->needsRebuild = true;
        }
    }

    /**
     * 拡張側が register する用の Extension 登録窓口。
     *
     * 同型クラスの二重登録は最後の登録を採用する (Twig\Environment::addExtension は
     * 同型クラスを二度 attach すると例外を投げるため)。
     *
     * @param AbstractExtension $extension
     * @return void
     */
    public function registerExtension(AbstractExtension $extension): void
    {
        $class = get_class($extension);
        $this->pendingExtensions[$class] = $extension;
        if ($this->twig !== null) {
            $this->needsRebuild = true;
        }
    }

    /**
     * テンプレ名と変数を渡してレンダリングする。
     *
     * Environment 未構築なら最低限の構築を行う。テーマは config('theme') を採用し、
     * 解決できなければ system のみを fallback とする。
     *
     * @param string $name
     * @param array<string, mixed> $context
     * @return string
     */
    public function renderTemplate(string $name, array $context = []): string
    {
        if ($this->twig === null || $this->needsRebuild) {
            $theme = $this->theme !== '' ? $this->theme : config('theme');
            $this->bootstrapEnvironment($theme);
        }
        assert($this->twig !== null);
        return $this->twig->render($name, $context);
    }

    /**
     * レンダリング (既存 API)。
     *
     * @return string
     */
    public function render(): string
    {
        if ($this->twig === null || $this->needsRebuild) {
            $this->bootstrapEnvironment($this->theme);
        }
        assert($this->twig !== null);
        return $this->twig->render($this->path);
    }

    /**
     * Twigのキャッシュをクリア
     *
     * @return void
     */
    public function clearCache(): void
    {
        $cacheDir = CACHE_DIR . 'twig';
        if (!LocalStorage::isDirectory($cacheDir)) {
            return;
        }
        $files = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($files as $fileinfo) {
            if ($fileinfo->isDir()) {
                LocalStorage::removeDirectory($fileinfo->getRealPath());
            } else {
                LocalStorage::remove($fileinfo->getRealPath());
            }
        }
    }

    /**
     * Environment を構築する。
     *
     * テーマ → system の順でテンプレートディレクトリを積み、組込み拡張・Hook 拡張・
     * pending のテンプレートパス / Filter / Function / Extension を flush する。
     *
     * @param string $theme
     * @return void
     */
    protected function bootstrapEnvironment(string $theme): void
    {
        $themeDirectories = $this->resolveThemeDirectories($theme);

        $loader = new CustomLoader($themeDirectories);
        // system テーマは @system 名前空間でも参照できるようにする。
        // 拡張アプリが {% extends '@system/admin/_layouts/extension.twig' %} で
        // 共通レイアウトを継承するためのフックポイント。
        $systemPath = THEMES_DIR . 'system';
        if (LocalStorage::isDirectory($systemPath)) {
            $loader->addPath($systemPath, 'system');
        }

        $defaultFilters = array_keys((new Environment($loader))->getFilters());

        $twig = new Environment($loader, [
            'cache' => CACHE_DIR . 'twig',
            'debug' => isDebugMode(),
        ]);

        $this->attachBuiltInExtensions($twig, $defaultFilters);

        // カスタムタグ
        $twig->addTokenParser(new MarkdownTokenParser());

        $this->twig = $twig;
        $this->loader = $loader;

        // Hook で拡張登録 (拡張アプリ・ユーザー拡張)。
        // Hook 内で addTemplatePath / registerFilter / registerFunction /
        // registerExtension を呼ぶと pending に積まれるので、後段の flush で反映する。
        $this->dispatchExtendsTwigHook();

        $this->flushPending();

        $this->needsRebuild = false;
    }

    /**
     * extendsTwig Hook を発火する。
     *
     * 通常運用では HOOK_ENABLE が無効化されているとき (config.user.php で 0 など) に
     * Hook を呼ばないようガードする。テストサブクラスから上書きすることで、Hook 発火を
     * 強制できる単体テスト経路も確保する。
     *
     * @return void
     */
    protected function dispatchExtendsTwigHook(): void
    {
        if (!defined('HOOK_ENABLE') || !HOOK_ENABLE) {
            return;
        }
        HookFactory::singleton()->call('extendsTwig', [$this]);
    }

    /**
     * 組込みの Twig 拡張を Environment に attach する。
     *
     * 拡張ポイントとして protected で切り出している。テストサブクラスから
     * 空実装で上書きすることで、ACMS の完全初期化を必要としない単体テストが書ける。
     * 本番コードの呼び出し経路では従来通り常に組込み拡張がすべて attach される。
     *
     * @param Environment $twig
     * @param array<int, string> $defaultFilters Twig 標準 Filter 名一覧 (AcmsExtension が利用)
     * @return void
     */
    protected function attachBuiltInExtensions(Environment $twig, array $defaultFilters): void
    {
        $acmsExtension = new AcmsExtension();
        $acmsExtension->setDefaultFilters($defaultFilters);
        $twig->addExtension($acmsExtension);
        $twig->addExtension(new DebugExtension());
        $twig->addExtension(new CommentedIncludeExtension());
        $twig->addExtension(new AcmsConfigExtension());
    }

    /**
     * テーマ名からテンプレートディレクトリリストを解決する。
     *
     * 親テーマ表記 ({子}@{親}) を再帰的に展開し、最後に system を必ず加える。
     * Twig\Loader\FilesystemLoader は存在しないパスの追加で例外を投げるため、
     * 実在するディレクトリのみに絞り込む。
     *
     * @param string $theme
     * @return array<int, string>
     */
    private function resolveThemeDirectories(string $theme): array
    {
        $themeDirectories = [];
        while ($theme !== '') {
            $themeDirectories[] = THEMES_DIR . $theme;
            $next = preg_replace('/^[^@]*?(@|$)/', '', $theme);
            if ($next === $theme) {
                break;
            }
            $theme = (string) $next;
        }
        $themeDirectories[] = THEMES_DIR . 'system';

        $existing = [];
        foreach ($themeDirectories as $dir) {
            if (LocalStorage::isDirectory($dir) && !in_array($dir, $existing, true)) {
                $existing[] = $dir;
            }
        }
        return $existing;
    }

    /**
     * pending に積まれた登録物を Environment / Loader に一括 attach する。
     */
    private function flushPending(): void
    {
        if ($this->loader === null || $this->twig === null) {
            return;
        }
        foreach ($this->pendingTemplatePaths as $namespace => $paths) {
            foreach ($paths as $path) {
                $this->loader->addPath($path, $namespace);
            }
        }
        foreach ($this->pendingExtensions as $extension) {
            // bootstrap で組込み拡張として既に attach されているクラスは
            // 二重登録を避けるためスキップする。
            $class = get_class($extension);
            if ($this->twig->hasExtension($class)) {
                continue;
            }
            $this->twig->addExtension($extension);
        }
        foreach ($this->pendingFilters as $filter) {
            $this->twig->addFilter(new TwigFilter($filter['name'], $filter['callable'], $filter['options']));
        }
        foreach ($this->pendingFunctions as $function) {
            $this->twig->addFunction(new TwigFunction($function['name'], $function['callable'], $function['options']));
        }
    }
}
