<?php

declare(strict_types=1);

namespace Acms\Services\Field;

use Acms\Services\Common\GroupFieldDiffCalculator;
use Acms\Services\Facades\Database as DB;
use Acms\Services\Facades\Image;
use Acms\Services\Facades\Logger as AcmsLogger;
use Acms\Services\Facades\PublicStorage;
use Field;
use SQL;

/**
 * カスタムフィールド image/file 型の物理ファイルのうち、どこからも参照されなくなったものを回収する。
 *
 * 削除候補の集め方は 2 系統ある。
 *  - グループフィールドの行削除で孤児化した @path（{@see collectOrphans()}。DB の DELETE 前に収集する）
 *  - 削除チェック・差し替えで extract() が Field のメタ情報 'removeOld' に積んだ予約（{@see collectMarked()}）
 * 実際の物理削除は DB の DELETE → INSERT が完了した後に {@see removeUnreferenced()} で行う。
 * DB 更新後であれば「自分自身を参照から除外する」条件が不要になり、参照チェックが素直に書ける。
 *
 * Why not FieldRepository に置いたままにしない: 永続化（save/load/delete）と「不要ファイルの回収」は
 * 変更理由が異なり、FieldRepository の 624 行のうち 197 行を GC が占めていた。
 * save() からの呼び出しは 3 箇所だけで切り出しやすい。
 */
class FieldFileGarbageCollector
{
    /**
     * メディアファイルの物理削除を通知するコールバック（削除した実パスを1引数で受ける）
     *
     * @var (callable(string): void)|null
     */
    private $onMediaDelete;

    /**
     * Why not クラス内で ACMS_Hook を直接呼ばない: フック発火はグローバル状態（HookFactory /
     * HOOK_ENABLE）へのアクセスを伴うため、呼び出し側の private メソッドに閉じる規約
     * （development-guidelines.md「サービス層」、範例 Services/HtmlPurifier）に従い、本クラスは
     * 受け取ったコールバックを所定タイミングで呼ぶだけにする。既定 null は、コンテナを経由せず
     * 直接 new する単体テスト・既存コードのためのフォールバック（フックを発火しない）。
     *
     * @param (callable(string): void)|null $onMediaDelete
     */
    public function __construct(?callable $onMediaDelete = null)
    {
        $this->onMediaDelete = $onMediaDelete;
    }

    /**
     * グループフィールド行削除で孤児化する @path のリストを収集する。
     *
     * save() の DB DELETE 前に呼び出す。DB に残っている @path 値のうち、
     * 今回保存しようとしている Field に含まれていないものを「削除された行」とみなし、
     * パスと image/file の種別をペアで返す。
     *
     * 実際の物理削除は DELETE → INSERT 完了後に removeUnreferenced() で行う。
     *
     * @param string $tableName 'field' | 'field_rev'
     * @param string $type 'bid'|'uid'|'cid'|'mid'|'eid'|'unit_id'
     * @param int|string $id
     * @param Field|null $field 保存しようとしている新 Field
     * @param int|null $rvid リビジョンID（field_rev テーブルの場合のみ使用）
     * @return list<array{path: string, type: 'image'|'file'}> 削除候補パスのリスト
     */
    public function collectOrphans(string $tableName, string $type, $id, $field, ?int $rvid = null): array
    {
        // DB 側の既存値を field_key ごとに収集
        $SQL = SQL::newSelect($tableName);
        $SQL->addSelect('field_key');
        $SQL->addSelect('field_value');
        $SQL->addWhereOpr('field_' . $type, $id);
        if ($tableName === 'field_rev') {
            $SQL->addWhereOpr('field_rev_id', $rvid);
        }
        // updateField 指定時は該当キーのみ対象
        if ($field instanceof Field && $field->get('updateField') === 'on') {
            $fkey = [];
            foreach ($field->listFields() as $fd) {
                if ($fd === 'updateField') {
                    continue;
                }
                $fkey[] = $fd;
            }
            if ($fkey === []) {
                return [];
            }
            $SQL->addWhereIn('field_key', $fkey);
        }
        $stmt = DB::query($SQL->get(dsn()), 'exec');

        /** @var array<string, string[]> */
        $dbValuesByKey = [];
        while ($row = DB::next($stmt)) {
            $dbValuesByKey[$row['field_key']][] = (string)$row['field_value'];
        }

        $candidates = [];
        foreach ($dbValuesByKey as $fd => $dbValues) {
            if (!str_ends_with($fd, '@path')) {
                continue;
            }
            $postValues = $field instanceof Field ? array_map('strval', $field->getArray($fd, true)) : [];
            $removedPaths = GroupFieldDiffCalculator::calculateRemovedRows($dbValues, $postValues);
            if ($removedPaths === []) {
                continue;
            }

            // image フィールドか file フィールドかを判定
            // 同一 base の @largePath キーが DB にあれば image 扱い
            $base = substr($fd, 0, -strlen('@path'));
            $fieldType = array_key_exists($base . '@largePath', $dbValuesByKey) ? 'image' : 'file';

            foreach ($removedPaths as $relPath) {
                if ($relPath === '') {
                    continue;
                }
                $candidates[] = ['path' => $relPath, 'type' => $fieldType];
            }
        }
        return $candidates;
    }

    /**
     * Field のメタ情報 'removeOld' に積まれた削除予約を収集する。
     *
     * extract() 内で FieldExtractor::markFieldFileForRemoval() によって積まれた削除候補を
     * removeUnreferenced() と同じ形式のリストに変換して返す。
     *
     * @param Field|null $field
     * @return list<array{path: string, type: 'image'|'file'}>
     */
    public function collectMarked($field): array
    {
        if (!$field instanceof Field) {
            return [];
        }
        $candidates = [];
        foreach ($field->listFields() as $fd) {
            $marks = $field->getMeta($fd, 'removeOld');
            if (!is_array($marks) || $marks === []) {
                continue;
            }
            foreach ($marks as $mark) {
                if (!is_array($mark)) {
                    continue;
                }
                $relPath = isset($mark['path']) ? (string)$mark['path'] : '';
                $type = $mark['type'] ?? null;
                if ($relPath === '' || ($type !== 'image' && $type !== 'file')) {
                    continue;
                }
                $candidates[] = ['path' => $relPath, 'type' => $type];
            }
        }
        return $candidates;
    }

    /**
     * 削除候補パスのうち、field / field_rev テーブルのどこからも参照されていないものを物理削除する。
     *
     * save() の DELETE → INSERT 完了後に呼び出す。
     * DB 更新後なので自分自身の除外条件は不要。
     *
     * @param list<array{path: string, type: 'image'|'file'}> $candidates collectOrphans() と collectMarked() をマージした候補リスト
     */
    public function removeUnreferenced(array $candidates): void
    {
        foreach ($candidates as $candidate) {
            $relPath = $candidate['path'];
            if ($this->isReferencedByField($relPath)) {
                continue;
            }
            $this->deletePhysicalFieldFile($relPath, $candidate['type']);
        }
    }

    /**
     * カスタムフィールド image/file 型のファイルを物理削除する。
     *
     * @param string $relPath archives ディレクトリからの相対パス
     * @param 'image'|'file' $type
     */
    private function deletePhysicalFieldFile(string $relPath, string $type): void
    {
        if ($relPath === '') {
            return;
        }
        if (!PublicStorage::isSafeRelativePath($relPath)) {
            // カスタムフィールドの旧パス（POST の `{fd}[old]`）は検証を経ずに保存されうるため、
            // 物理削除の直前にアーカイブ領域の外へ出ないことを確認する
            AcmsLogger::warning('アーカイブ領域の外を指すファイルパスの削除を拒否しました', [
                'path' => $relPath,
            ]);
            return;
        }
        if ($type === 'image') {
            Image::deleteImageAllSize(ARCHIVES_DIR . normalSizeImagePath($relPath));
            return;
        }
        $target = ARCHIVES_DIR . $relPath;
        PublicStorage::remove($target);
        if ($this->onMediaDelete !== null) {
            ($this->onMediaDelete)($target);
        }
    }

    /**
     * 指定パスが field / field_rev テーブルのいずれかのレコードで使われているか判定する。
     *
     * @param string $path 検査するファイルパス（相対パス）
     * @return bool いずれかのレコードが参照していれば true
     */
    private function isReferencedByField(string $path): bool
    {
        $sql = SQL::newSelect('field');
        $sql->setSelect('field_value');
        $sql->addWhereOpr('field_value', $path);
        $sql->setLimit(1);
        if (DB::query($sql->get(dsn()), 'one')) {
            return true;
        }

        $sql = SQL::newSelect('field_rev');
        $sql->setSelect('field_value');
        $sql->addWhereOpr('field_value', $path);
        $sql->setLimit(1);
        if (DB::query($sql->get(dsn()), 'one')) {
            return true;
        }

        return false;
    }
}
