<?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 Acms\Services\Template\Twig\TwigOptions;
use LogicException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Twig\Environment;
use Twig\Extension\AbstractExtension;
use Twig\Extension\DebugExtension;
use Twig\Extension\SandboxExtension;
use Twig\Loader\ArrayLoader;
use Twig\Loader\ChainLoader;
use Twig\Loader\FilesystemLoader;
use Twig\Loader\LoaderInterface;
use Twig\Sandbox\SecurityPolicy;
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;

    /**
     * extendsTwig Hook が発火済みかどうか。
     *
     * bootstrapEnvironment で 1 回だけ発火させる。Environment を再構築 (needsRebuild) しても
     * 二重発火しないようフラグで管理する。
     */
    private bool $extendsHookDispatched = false;

    /**
     * Environment 構築の挙動を決める設定。null は公開側既定 (現行と同一)。
     *
     * サービスコンテナにキー違いで同じ Twig クラスを登録する際、クロージャ登録で注入する。
     *
     * @var TwigOptions|null
     */
    private ?TwigOptions $options;

    /**
     * renderString 用に文字列テンプレートを載せる ArrayLoader。
     * useStringLoader 有効時のみ生成される。
     *
     * @var ArrayLoader|null
     */
    private ?ArrayLoader $stringLoader = null;

    /**
     * @param TwigOptions|null $options Environment 構築の設定。null で公開側既定 (現行と同一)
     */
    public function __construct(?TwigOptions $options = null)
    {
        $this->options = $options;
    }

    /**
     * 解決済みの設定を返す。null のときは公開側既定を組み立てる。
     *
     * 既定はキャッシュディレクトリのみ実行時定数 (CACHE_DIR) を使うため、コンストラクタ既定値では
     * 表現できない。そのため null のときだけここで補う (他の既定値は TwigOptions の既定と一致)。
     */
    private function resolveOptions(): TwigOptions
    {
        return $this->options ?? new TwigOptions(cacheDir: CACHE_DIR . 'twig');
    }

    /**
     * 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);
    }

    /**
     * 文字列テンプレートをレンダリングする。
     *
     * useStringLoader を有効にした構成で使う。文字列を ArrayLoader に載せて render する。
     * useThemeLoader も有効なら、テンプレ内の include/extends 先はテーマ階層から解決される。
     *
     * @param string $template レンダリングするテンプレート文字列
     * @param array<string, mixed> $context
     * @param string $name autoescape 戦略がテンプレ名を参照する場合に使う論理名 (拡張子を含めると良い)
     * @return string
     * @throws LogicException useStringLoader 無効の構成で呼ばれた場合
     */
    public function renderString(string $template, array $context = [], string $name = 'string_template'): string
    {
        if ($this->twig === null || $this->needsRebuild) {
            $theme = $this->theme !== '' ? $this->theme : config('theme');
            $this->bootstrapEnvironment($theme);
        }
        assert($this->twig !== null);
        if ($this->stringLoader === null) {
            throw new LogicException('renderString requires the useStringLoader option to be enabled.');
        }
        $this->stringLoader->setTemplate($name, $template);
        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
    {
        $options = $this->resolveOptions();

        $loader = $this->createLoader($theme, $options);

        $envOptions = [
            'cache' => $options->cacheDir,
            'debug' => isDebugMode(),
        ];
        // autoescape は null なら設定しない (= Twig 既定に委ねる)。公開側の現挙動を保つ。
        if ($options->autoescape !== null) {
            $envOptions['autoescape'] = $options->autoescape;
        }
        $twig = new Environment($loader, $envOptions);

        $defaultFilters = self::defaultFilterNames();
        // options->extensions に列挙された組込み拡張だけを attach する。
        // サンドボックス経路は [EXTENSION_ACMS] のように絞られ、DebugExtension 等は持ち込まれない。
        $this->attachBuiltInExtensions($twig, $defaultFilters);

        // カスタムタグ
        if ($options->markdown) {
            $twig->addTokenParser(new MarkdownTokenParser());
        }

        $this->twig = $twig;

        // Hook で拡張登録 (拡張アプリ・ユーザー拡張)。Hook 内で addTemplatePath / registerFilter /
        // registerFunction / registerExtension を呼ぶと pending に積まれ、後段の flush で反映される。
        // ただしサンドボックス経路 (運営者のフォーム直接入力) では、第三者拡張の独自フィルタ/関数/拡張を
        // 持ち込ませないため extendsTwig Hook を発火しない (許可リストにも合成しない)。
        if (!$options->isSandboxed()) {
            $this->dispatchExtendsTwigHook();
        }

        $this->flushPending();

        // サンドボックスは Environment 構築の最後に attach する。許可リストは
        // 「基本 (options) + AcmsExtension 由来」のみ (extendsTwig 由来は含めない)。
        if ($options->isSandboxed()) {
            $twig->addExtension(new SandboxExtension($this->buildSandboxPolicy($options), true));
        }

        $this->needsRebuild = false;
    }

    /**
     * options に応じて Environment 用のローダを構築する。
     *
     * - テーマローダ ({@see CustomLoader}): include/extends・パス指定 render に必要
     * - 文字列ローダ (ArrayLoader): {@see renderString} で文字列テンプレを載せる
     * 両方有効なら ChainLoader[文字列, テーマ] とし、文字列テンプレを主に include 先をテーマから解決する。
     *
     * テーマローダの参照は $this->loader に保持し flushPending / addTemplatePath から、
     * 文字列ローダは $this->stringLoader に保持し renderString から使う。
     */
    private function createLoader(string $theme, TwigOptions $options): LoaderInterface
    {
        $loaders = [];

        if ($options->useStringLoader) {
            $this->stringLoader = new ArrayLoader([]);
            $loaders[] = $this->stringLoader;
        } else {
            $this->stringLoader = null;
        }

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

        if (count($loaders) === 1) {
            return $loaders[0];
        }
        return new ChainLoader($loaders);
    }

    /**
     * サンドボックスの許可リストを組み立てる。
     *
     * 基本許可 (options 由来) に、AcmsExtension のフィルタ/関数名を合成する。これにより運営者入力でも
     * acms_* フィルタ・module() などの組込み関数が許可リスト内で使える。タグはオプション指定のみ
     * (include/extends を許可リストに入れなければ運営者は他ファイルを取り込めない)。
     *
     * 拡張アプリ・ユーザー拡張の extendsTwig Hook 由来 (pending) のフィルタ/関数は、サンドボックス経路では
     * そもそも発火させない ({@see bootstrapEnvironment}) ため、ここでも合成しない。第三者拡張の独自フィルタ・
     * 関数を運営者の直接入力で実行させないための境界。
     *
     * strict モードを必ず有効化する。Twig\Sandbox\SecurityPolicy は後方互換のため、strict=false だと
     * `extends` / `use` タグと `parent` / `block` / `attribute` 関数を許可リストの有無に関わらず常に
     * 通してしまう (Twig 3.12 で非推奨化、4.0 で廃止予定の挙動)。strict 化しない限り、許可タグから
     * `extends` を外しても運営者入力の `{% extends "自身の論理名" %}` (自己参照) が実行され、
     * 無限再帰でメモリ枯渇 (Fatal Error) に至る。
     */
    private function buildSandboxPolicy(TwigOptions $options): SecurityPolicy
    {
        $filters = $options->sandboxFilters;
        $functions = $options->sandboxFunctions;

        // buildSandboxPolicy は bootstrapEnvironment の最後 (twig 構築・AcmsExtension attach 済み) に呼ばれる。
        // ここで AcmsExtension を再生成すると defaultFilterNames() の使い捨て Environment 生成と
        // インスタンス生成が二重になるため、attach 済みインスタンスからフィルタ/関数名を集める。
        // サンドボックス構成は EXTENSION_ACMS を含む前提だが、含まない構成でも空合成で安全に動く。
        if ($this->twig !== null && $this->twig->hasExtension(AcmsExtension::class)) {
            $acmsExtension = $this->twig->getExtension(AcmsExtension::class);
            foreach ($acmsExtension->getFilters() as $filter) {
                $filters[] = $filter->getName();
            }
            foreach ($acmsExtension->getFunctions() as $function) {
                $functions[] = $function->getName();
            }
        }

        $policy = new SecurityPolicy(
            $options->sandboxTags ?? [],
            array_values(array_unique($filters)),
            [],
            [],
            array_values(array_unique($functions)),
        );
        $policy->setStrict(true);
        return $policy;
    }

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

    /**
     * 組込みの Twig 拡張を Environment に attach する。
     *
     * attach する拡張は {@see TwigOptions::$extensions} で指定されたキー集合に従う
     * (公開側・メールのファイル経路は既定のフルセット、サンドボックス経路は [EXTENSION_ACMS] のみ等)。
     * 拡張ポイントとして protected で切り出しており、テストサブクラスから空実装で上書きすることで、
     * ACMS の完全初期化を必要としない単体テストが書ける。
     *
     * @param Environment $twig
     * @param array<int, string> $defaultFilters Twig 標準 Filter 名一覧 (AcmsExtension が利用)
     * @return void
     */
    protected function attachBuiltInExtensions(Environment $twig, array $defaultFilters): void
    {
        foreach ($this->resolveOptions()->extensions as $key) {
            $extension = self::makeBuiltInExtension($key, $defaultFilters);
            if ($extension !== null) {
                $twig->addExtension($extension);
            }
        }
    }

    /**
     * 組込み拡張キー ({@see TwigOptions::EXTENSION_ACMS} 等) を拡張インスタンスへ解決する。
     *
     * 未知のキーは null を返して無視する (呼び出し側で skip)。AcmsExtension のみ defaultFilters を要するため
     * {@see createAcmsExtension} 経由で生成する。
     *
     * @param string $key 拡張キー
     * @param array<int, string> $defaultFilters Twig 標準 Filter 名一覧 (AcmsExtension が利用)
     * @return AbstractExtension|null
     */
    private static function makeBuiltInExtension(string $key, array $defaultFilters): ?AbstractExtension
    {
        return match ($key) {
            TwigOptions::EXTENSION_ACMS => self::createAcmsExtension($defaultFilters),
            TwigOptions::EXTENSION_DEBUG => new DebugExtension(),
            TwigOptions::EXTENSION_COMMENTED_INCLUDE => new CommentedIncludeExtension(),
            TwigOptions::EXTENSION_ACMS_CONFIG => new AcmsConfigExtension(),
            default => null,
        };
    }

    /**
     * Twig 標準 Filter 名の一覧を返す。
     *
     * {@see AcmsExtension::setDefaultFilters} に渡し、Twig 標準と衝突する Corrector 由来
     * フィルタ (nl2br など) を `acms_` プレフィックス版のみに絞り込むために使う。
     * 公開側 Environment・メール用 Trusted/Sandboxed の各レンダラで同じ値を共有する。
     *
     * @return array<int, string>
     */
    public static function defaultFilterNames(): array
    {
        return array_keys((new Environment(new ArrayLoader([])))->getFilters());
    }

    /**
     * 組込みの Twig 拡張フルセット ({@see TwigOptions::DEFAULT_EXTENSIONS}) を Environment に attach する。
     *
     * 拡張構成を 1 箇所に集約するためのユーティリティ。`dump` を提供する DebugExtension を含むため、
     * 運営者入力テンプレを評価するサンドボックス経路では使わず、{@see TwigOptions::$extensions} を
     * [EXTENSION_ACMS] のように絞る。インスタンス側の attach は {@see attachBuiltInExtensions} を参照。
     *
     * @param array<int, string>|null $defaultFilters null のとき {@see defaultFilterNames} を使う
     * @return void
     */
    public static function applyBuiltInExtensions(Environment $twig, ?array $defaultFilters = null): void
    {
        $defaultFilters = $defaultFilters ?? self::defaultFilterNames();
        foreach (TwigOptions::DEFAULT_EXTENSIONS as $key) {
            $extension = self::makeBuiltInExtension($key, $defaultFilters);
            if ($extension !== null) {
                $twig->addExtension($extension);
            }
        }
    }

    /**
     * `setDefaultFilters` 済みの {@see AcmsExtension} を生成して返す。
     *
     * サンドボックス経路 (template.mail.twig.sandboxed / extensions=[EXTENSION_ACMS]) は DebugExtension 等の
     * 危険な拡張を避けつつ AcmsExtension のフィルタ・関数だけを許可リストに合成したいため、フル attach を行う
     * {@see applyBuiltInExtensions} ではなくこちらを使う。
     *
     * @param array<int, string>|null $defaultFilters null のとき {@see defaultFilterNames} を使う
     * @return AcmsExtension
     */
    public static function createAcmsExtension(?array $defaultFilters = null): AcmsExtension
    {
        $extension = new AcmsExtension();
        $extension->setDefaultFilters($defaultFilters ?? self::defaultFilterNames());
        return $extension;
    }

    /**
     * テーマ名からテンプレートディレクトリリストを解決する。
     *
     * 親テーマ表記 ({子}@{親}) を再帰的に展開し、最後に 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->twig === null) {
            return;
        }
        // テーマローダを持たない構成 (サンドボックスの文字列専用) でも Filter / Function は反映する。
        if ($this->loader !== null) {
            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']));
        }
    }
}
