<?php

declare(strict_types=1);

namespace pictpostpersonal\model;

use DateTime;
use Exception;
use InvalidArgumentException;
use PDO;
use pictpostpersonal\Environment;
use pictpostpersonal\ImageUtility;
use pictpostpersonal\SQL;
use pictpostpersonal\SQLiteCRUD;
use pictpostpersonal\Utility;

class Article
{
    private PDO $db;
    private SQLiteCRUD $sql_crud;
    private Setting $setting;
    private array $field_info;

    public function __construct(\PDO $db, Setting $setting)
    {
        $this->db = $db;
        $this->sql_crud = new SQLiteCRUD($db);
        $this->setting = $setting;

        // フィールド情報 (フィールド名, 初期値, is_Bool)
        $this->field_info = [
            ['id', '0', '0'],               //連番ID
            ['type', '0', '0'],             //記事タイプ (0:通常記事, 1:固定ページ, 2:トップページ, 3:エントリーページ, 4:NSFW)
            ['status', '0', '0'],           //ステータス (0:公開, 1:下書き)
            ['hidden_at_list', '0', '1'],   //リスト非表示フラグ(0:表示, 1:非表示)
            ['created_at', '', '0'],        //作成日時
            ['updated_at', '', '0'],        //更新日時
            ['title', '', '0'],             //タイトル
            ['contents', '', '0'],           //コメント
            ['tags', '', '0'],              //タグ
            ['color', '', '0'],             //カラー
            ['style', '0', '0'],            //スタイル
            ['pin', '0', '0'],              //ピン留めフラグ
            ['sort', '1', '0'],             //ピン止めソート順
            ['nsfw', '0', '0'],             //NSFWフラグ
            ['like', '0', '0'],             //いいね数
            ['views', '0', '0'],            //閲覧数
            ['from_ip', '', '0'],           //投稿元IPアドレス
            ['delete_key', '', '0'],        //削除キー（ハッシュ値）
            ['use_alt_ogimage', '0', '0'],  //代替OGイメージ使用フラグ
            ['alt_url', '0', '0'],          //固定URL
            ['show_password', '', '0'],     //表示パスワード
            ['template', '', '0'],          //テンプレート
            ['thumbnail_type', '0', '0'],   //サムネイルタイプ(0:自動, 1:1枚目の画像, 2:すべて表示, 3:指定画像, 4:サムネイル無し)
            ['thumbnail', '', '0'],         //サムネイル画像ファイル名
        ];
    }

    public static function initialize(SQLiteCRUD $sql_crud)
    {
        $sql_crud->table('articles')->createTable(
            [
                SQL::Id('id'),
                SQL::Integer('type'),
                SQL::Text('status'),
                SQL::Bool('hidden_at_list'),
                SQL::DateTime('created_at'),
                SQL::DateTime('updated_at'),
                SQL::Text('title'),
                SQL::Text('contents'),
                SQL::Text('tags'),
                SQL::Text('color'),
                SQL::Integer('style'),
                SQL::Bool('pin', false),
                SQL::Integer('sort'),
                SQL::Bool('nsfw', false),
                SQL::Integer('like'),
                SQL::Integer('views'),
                SQL::Text('from_ip'),
                SQL::Text('delete_key'),
                SQL::Bool('use_alt_ogimage', false),
                SQL::Text('alt_url'),
                SQL::Text('show_password', ''),
                SQL::Text('template', ''),
                SQL::Integer('thumbnail_type'),
                SQL::Text('thumbnail'),
            ],
            [
                'alt_url',
                'created_at,type,status',
                'updated_at,type,status',
                'type',
            ]
        );

        $sql_crud->table('article_editings')->createTable(
            [
                SQL::Id('id'),
                SQL::Integer('type'),
                SQL::Integer('article_id'),
                SQL::Text('status'),
                SQL::Bool('hidden_at_list'),
                SQL::DateTime('created_at'),
                SQL::DateTime('updated_at'),
                SQL::Text('title'),
                SQL::Text('contents'),
                SQL::Text('tags'),
                SQL::Text('color'),
                SQL::Integer('style'),
                SQL::Bool('pin', false),
                SQL::Integer('sort'),
                SQL::Bool('nsfw', false),
                SQL::Integer('like'),
                SQL::Integer('views'),
                SQL::Text('from_ip'),
                SQL::Text('delete_key'),
                SQL::Bool('use_alt_ogimage', false),
                SQL::Text('alt_url'),
                SQL::Text('show_password', ''),
                SQL::Text('template', ''),
                SQL::Integer('thumbnail_type'),
                SQL::Text('thumbnail'),
            ],
            [
                'alt_url',
                'created_at,type,status',
                'updated_at,type,status',
                'type',
            ]
        );
    }

    private function extractFileInfo(array $files, array $values, string $type): array
    {
        $file_info = [];
        if (isset($files[$type])) {
            foreach ($files[$type] as $index => $file) {
                $tmp = [
                    'file' => $file->name,
                    'comment' => $values['image_comments'][$index] ?? '',
                    'sort' => $values['image_sort'][$index] ?? 0,
                ];
                $file_info[$file->tmp_name] = $tmp;
            }
        }
        return $file_info;
    }

    /**
     * 記事を新規保存し、そのIDを返す。
     * メディアファイルを保存する場合はこのIDを使用する
     *
     * @param array $values 保存する値の連想配列。キー名をフィールドとする。
     * @param array $files  添付ファイルの配列
     *
     * @return int 新規に割り振られたID
     *
     */
    public function create(array $values, array $files, $use_transaction = true): int
    {
        // TODO:Guestモード対応
        $this->validate($values, true, false);

        try {
            if ($use_transaction)
                $this->sql_crud->begin();

            $article_id = $this->writeTable($values);

            // 添付ファイル処理
            $file_info = array_merge(
                $this->extractFileInfo($files, $values, 'images'),
                $this->extractFileInfo($files, $values, 'movies')
            );

            $use_password = preg_match("/\{password/i", $values['contents']);
            $use_rnd = $this->setting->getValue("use_rnd_filename", false) || $use_password;

            if (count($file_info) > 0) {
                $sub_dir = Utility::getCurrentShortMonth() . '/';
                $this->mediaUpload($article_id, $sub_dir, $files, $file_info, $use_rnd);
            }
            if ($use_transaction)
                $this->sql_crud->commit();

            return $article_id;
        } catch (Exception $ex) {
            if ($use_transaction)
                $this->sql_crud->rollback();
            throw $ex;
        }
    }

    /**
     * 記事を上書き更新する。
     *
     * @param array $values 保存する値の連想配列。キー名をフィールドとする。
     * @param array $files  添付ファイルの配列
     *
     * @return int ID
     *
     */
    public function update(array $values, array $files, bool $use_transaction = true): int
    {
        // TODO:ゲストモード対応

        if (!isset($values['id']) || $values['id'] === '') {
            throw new Exception('IDが設定されていません');
        }
        if (!is_numeric($values['id'])) {
            throw new Exception('IDが不正です');
        }

        $this->validate($values, true, false);

        try {
            if ($use_transaction)
                $this->sql_crud->begin();

            $id = $this->writeTable($values);

            // メディアファイル登録
            $file_info = array_merge(
                $this->extractFileInfo($files, $values, 'images'),
                $this->extractFileInfo($files, $values, 'movies')
            );
            $use_password = preg_match("/\{password/i", $values['contents']);
            $use_rnd = $this->setting->getValue("use_rnd_filename", false) || $use_password;
            if (count($file_info) > 0) {
                $sub_dir = Utility::getCurrentShortMonth() . '/';
                $this->mediaUpload($id, $sub_dir, $files, $file_info, $use_rnd);
            }

            $media = new Media($this->db, $this->setting);

            // メディア削除
            $delete_files = [];
            if (!empty($values['remove_files'])) {
                $deletes = explode(';', $values['remove_files']);
                foreach ($deletes as $delete_image) {
                    $data = $media->getByFileName($delete_image);
                    if ($data) {
                        foreach ($data as $media_item) {
                            $delete_files[] = "./images/{$media_item['sub_dir']}{$media_item['file_name']}";
                            $delete_files[] = "./images/{$media_item['sub_dir']}{$media_item['thumb_name']}";
                            $delete_files[] = "./images/{$media_item['sub_dir']}{$media_item['thumb_l_name']}";
                        }
                        $media->deleteByMediaId($data[0]['id']);
                    }
                }
            }

            // 既存メディア情報更新
            if (isset($values['updated_image_id'])) {
                foreach ($values['updated_image_id'] as $index => $media_id) {
                    $comment = $values['updated_image_comments'][$index] ?? '';
                    $sort = $values['updated_image_sort'][$index] ?? 0;
                    $media->updateInfoById((int)$media_id, (int)$sort, $comment);
                }
            }

            if ($use_transaction)
                $this->sql_crud->commit();

            //メディアファイル実体削除
            foreach ($delete_files as $delete_file) {
                if (file_exists($delete_file)) {
                    unlink($delete_file);
                }
            }


            return (int)$id;
        } catch (Exception $ex) {
            if ($use_transaction)
                $this->sql_crud->rollback();
            throw $ex;
        }
    }

    /**
     * 記事を削除する。
     *
     * @param int $id 記事ID
     *
     * @return void
     */
    private function delete($id): void
    {
        $this->deleteMediaByArticleId($id);
        $this->sql_crud->table('articles')->where('id = :id', ['id' => $id])->delete();
    }

    /**
     * 複数の記事を削除する。
     *
     * @param array $idList 削除対象IDのリスト [1,2,3,4]
     *
     * @return void
     */
    public function deleteByIDList(array $id_list): void
    {
        $this->sql_crud->begin();
        try {
            foreach ($id_list as $id) {
                $this->delete((int)$id);
            }
        } catch (Exception $ex) {
            $this->sql_crud->rollback();
            throw $ex;
        }
        $this->sql_crud->commit();
    }

    /**
     * 記事を削除する（ゲストモード）。
     *
     * @param int    $id  記事ID
     * @param string $key 削除キー（平文）
     *
     * @return void
     *
     */
    public function guestDelete($id, string $key): void
    {
        if ($this->validateDeleteKey($id, $key)) {
            $this->deleteMediaByArticleId($id);
            $this->delete($id);
        } else {
            throw new Exception('削除キーが無効です');
        }
    }

    /**
     * 既存の記事にメディアを追加する
     *
     * @param int   $articleId 記事ID
     * @param array $values    image_commentsを含むフォーム情報
     * @param File[] $files     画像ファイル ($_FILES)
     *
     * @return int 画像ID
     *
     */
    public function addMedia($article_id, array $values, array $files): int
    {
        $article = $this->getbyid($article_id);
        $use_password = preg_match("/\{password/i", $article['contents']);
        $use_rnd = $this->setting->getValue("use_rnd_filename", false) || $use_password;

        $file_info = [];

        if (isset($files['images'])) {
            foreach ($files['images'] as $index => $file) {
                $tmp = [
                    'file_name' => $file->name,
                    'comment' => $values['image_comments'][$index] ?? '',
                    'sort' => $values['image_sort'][$index] ?? 0,
                ];
                $file_info[$file->tmp_name] = $tmp;
            }
        }

        if (isset($files['movies'])) {
            foreach ($files['movies'] as $index => $file) {
                $tmp = [
                    'file_name' => $file->name,
                    'comment' => $values['image_comments'][$index] ?? '',
                    'sort' => $values['image_sort'][$index] ?? 0,
                ];
                $file_info[$file->tmp_name] = $tmp;
            }
        }

        if (count($file_info) > 0) {
            $sub_dir = Utility::getCurrentShortMonth() . '/';
            $this->mediaUpload($article_id, $sub_dir, $files, $file_info, $use_rnd);
        }

        return 0;
    }


    /**
     * メディアデータを削除する。
     * メディア自体も同時に削除される。
     *
     * @param int $mediaId メディアID
     *
     * @return void
     *
     */
    public function deleteMediaByArticleId($article_id): void
    {
        $media = new Media($this->db, $this->setting);
        $media_list = $media->getByArticleId($article_id);

        foreach ($media_list as $media_item) {
            $sub_dir = $media_item['sub_dir'];
            $file_name = $media_item['file_name'];
            $thumb_name = $media_item['thumb_name'];
            $thumb_lname = $media_item['thumb_l_name'];

            @unlink("./images/{$sub_dir}{$file_name}");
            @unlink("./images/{$sub_dir}{$thumb_name}");
            @unlink("./images/{$sub_dir}{$thumb_lname}");

            if (Utility::isDirectoryEmpty("./images/{$sub_dir}")) {
                @rmdir("./images/{$sub_dir}");
            }
        }

        $media->deleteByArticleId($article_id);
    }

    /**
     * 検証処理
     *
     * @param array $values       検証対象の値
     * @param bool  $insertMode   挿入モードかどうか
     * @param bool  $guestMode    ゲストモードかどうか
     *
     * @return void
     *
     * @throws Exception 検証に失敗した場合
     */
    private function validate(array $values, bool $insert_mode, bool $guest_mode): void
    {
        if (!$insert_mode && (!isset($values['id']) || $values['id'] === '')) {
            throw new Exception('IDが設定されていません');
        }

        if (
            (empty($values['contents'] ?? null)) &&
            (empty($values['created_at'] ?? null))
        ) {
            // 日付もコメントもない場合はエラー
            throw new Exception('パラメータが不正です');
        }

        if (isset($values['alt_url'])) {
            $alt_url = trim($values['alt_url']);
            if (strpos($alt_url, '/') === 0) {
                throw new Exception('"/"から始まる固定URLは指定できません');
            }
            if (strpos($alt_url, 'api/') === 0) {
                throw new Exception('"api/"から始まる固定URLは指定できません');
            }
            if ($alt_url === 'log') {
                throw new Exception('"log"は固定URLとして指定できません');
            }
            if (preg_match('/^log\/\d+(\/.*)?$/i', $alt_url)) {
                throw new Exception('"log/(数字)"の固定URLは指定できません');
            }
        }

        if ($guest_mode && empty($values['delete_key'] ?? null)) {
            // ゲストモード時は削除キー必須
            throw new Exception('削除キーが無効です');
        }

        if (!$insert_mode) {
            if (!empty($values['delete_key'] ?? null)) {
                // ゲスト かつ 更新の場合はdelete_keyをチェックする
                if (!$this->validateDeleteKey((int)($values['id'] ?? 0), $values['delete_key'])) {
                    throw new Exception('削除キーが無効です');
                }
            }
        }
    }

    /**
     * テーブルへの書き込みを実施する
     *
     * @param array $values 書き込み対象の値
     *
     * @return int 記事ID
     *
     */
    private function writeTable(array $values): int
    {
        // 初期値を設定
        $data = [];
        foreach ($this->field_info as [$field, $default, $is_bool]) {
            $data[$field] = $default;
        }

        // ゲストモード判定
        $guest_mode = !empty($values['delete_key'] ?? null);

        if (Environment::isDemoMode() || $guest_mode) {
            // デモモード、ゲストモード時はタグをすべて削除
            foreach ($values as $index => $value) {
                if (!is_array($value)) {
                    $values[$index] = htmlspecialchars(strip_tags((string)$value ?? ''));
                }
            }
        }

        // 新規or更新判定
        $id = $values['id'] ?? '';

        // $valuesに存在するデータを$dataにセットする
        foreach ($this->field_info as [$field, $default, $is_bool]) {
            if (array_key_exists($field, $values)) {
                if ($is_bool) {
                    // BOOL型を0/1に修正
                    $data[$field] = $values[$field] ? 1 : 0;
                } else {
                    $data[$field] = $values[$field];
                }
            }
        }

        if (!empty($id)) {
            // 更新の場合はView, Likeを更新しない
            unset($data['views']);
            unset($data['like']);
        }

        // タグ成形
        // 単語ごとにスペースを1つ含む形に成形する
        if (!empty($values['tags'] ?? null)) {
            $tags = array_filter(explode(' ', trim($values['tags'])));
            $data['tags'] = ' ' . implode(' ', $tags) . ' ';
        }

        if (empty($data['created_at'])) {
            $data['created_at'] = Utility::getCurrentTime($this->setting->getValue('timezone', 'Asia/Tokyo'));
        }

        if ((isset($data['sort']) && !isset($data['pin'])) || (isset($data['sort']) && !is_numeric($data['sort']))) {
            // pin指定のないsortは無効
            $data['sort'] = 1;
        }

        // 更新日をセット
        $data['updated_at'] = Utility::getCurrentTime($this->setting->getValue('timezone', 'Asia/Tokyo'));

        if ($guest_mode) {
            $data['delete_key'] = password_hash($values['delete_key'], PASSWORD_DEFAULT);
        } else {
            $data['delete_key'] = null;
        }

        $data['from_ip'] = $values['from_ip'] ?? '';

        if (empty($id)) {
            // ID指定がない場合は新規作成
            unset($data['id']);
            $result_id = $this->sql_crud->table('articles')->create($data);
            $id = $result_id;
        } else {
            // ID指定がある場合は更新
            $this->sql_crud->table('articles')->where('id = :id', ['id' => (int)$id])->update($data);
        }

        return (int)$id;
    }

    /**
     * メディアをリサイズ・保存する
     *
     * @param int   $articleId 記事ID
     * @param string $subDir    サブディレクトリ
     * @param array $files      ファイル情報
     * @param array $fileInfo   ファイル詳細情報
     * @param bool  $use_rnd    ランダムファイル名を使用する
     * 
     * @return array
     *
     */
    private function mediaUpload($article_id, string $sub_dir, array $files, array $file_info, bool $use_random): array
    {
        // サブディレクトリ作成
        if (!is_dir("./images/{$sub_dir}")) {
            mkdir("./images/{$sub_dir}", 0755, true);
        }

        $new_file_names = [];
        $media = new Media($this->db, $this->setting);
        $image_idx = $media->getLastSortNumber($article_id) + 1;

        if (isset($files['images'])) {
            foreach ($files['images'] as $index => $file) {
                $file_name = $file->name;
                if ($use_random) {
                    //ファイル名にランダム性を持たせる
                    $rnd = "_" . bin2hex(random_bytes(20));
                } else {
                    $rnd = "";
                }

                $extension = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
                $new_file_name = "image_{$article_id}_{$image_idx}{$rnd}.{$extension}";
                $thumb_file_name = "image_{$article_id}_{$image_idx}{$rnd}.jpg"; // サムネイルはJPG固定

                // リサイズ
                $img_info = ImageUtility::createResizeImage(
                    $file->tmp_name,
                    "./images/{$sub_dir}{$new_file_name}",
                    (int)$this->setting->getValue('resize_border', 1980),
                    (int)$this->setting->getValue('resize_border', 1980),
                    100,
                    $this->setting->getValue('remove_exif', false) ?? false
                );
                chmod("./images/{$sub_dir}{$new_file_name}", 0644);

                // 小サムネイル作成
                $thumb_info = ImageUtility::createResizeImage(
                    $file->tmp_name,
                    "./images/{$sub_dir}thumb_{$thumb_file_name}",
                    (int)$this->setting->getValue('thumb_width', 300),
                    (int)$this->setting->getValue('thumb_height', 300),
                    90
                );
                chmod("./images/{$sub_dir}thumb_{$thumb_file_name}", 0644);

                // 大サムネイル作成
                $thumb_linfo = ImageUtility::createResizeImage(
                    $file->tmp_name,
                    "./images/{$sub_dir}thumb_l_{$thumb_file_name}",
                    (int)$this->setting->getValue('thumb_l_width', 900),
                    (int)$this->setting->getValue('thumb_l_height', 0),
                    90
                );
                chmod("./images/{$sub_dir}thumb_l_{$thumb_file_name}", 0644);

                // メディアテーブルに情報を保存
                $original_id = $media->add(
                    $article_id,
                    'image',
                    $new_file_name,
                    $sub_dir,
                    (int)$img_info['width'],
                    (int)$img_info['height'],
                    "thumb_l_{$thumb_file_name}",
                    (int)$thumb_linfo['width'],
                    (int)$thumb_linfo['height'],
                    "thumb_{$thumb_file_name}",
                    (int)$thumb_info['width'],
                    (int)$thumb_info['height'],
                    $file_info[$file->tmp_name]['comment'] ?? '',
                    (int)$file_info[$file->tmp_name]['sort'] ?? 0
                );

                $file_info[$file->tmp_name]['media_id'] = $original_id;
                unset($file_info[$file->tmp_name]);
                $image_idx++;
            }
        }

        if (isset($files['movies']) && Environment::isDemoMode() === false) {
            foreach ($files['movies'] as $index => $file) {
                $preview_tmp_name = $files['movie_previews'][$index]->tmp_name;
                $movie_file_name = $files['movies']['name'][$index];

                $extension = strtolower(pathinfo($movie_file_name, PATHINFO_EXTENSION));
                if (!in_array($extension, ['mp4', 'webm', 'ogg'], true)) {
                    // サポート外の拡張子はスキップ
                    continue;
                }
                if ($use_random) {
                    //ファイル名にランダム性を持たせる
                    $rnd = "_" . bin2hex(random_bytes(20));
                } else {
                    $rnd = "";
                }


                $new_file_name = "image_{$article_id}_{$image_idx}{$rnd}.{$extension}";
                $thumb_file_name = "image_{$article_id}_{$image_idx}{$rnd}.jpg"; // サムネイルはJPG固定

                // 動画はそのままコピー
                copy($file->tmp_name, "./images/{$sub_dir}{$new_file_name}");
                chmod("./images/{$sub_dir}{$new_file_name}", 0644);

                // 大サムネイル作成
                $thumb_linfo = ImageUtility::createResizeImage(
                    $preview_tmp_name,
                    "./images/{$sub_dir}thumb_l_{$thumb_file_name}",
                    (int)$this->setting->getValue('thumb_l_width', 900),
                    (int)$this->setting->getValue('thumb_l_height', 0),
                    80
                );
                chmod("./images/{$sub_dir}thumb_l_{$thumb_file_name}", 0644);

                // 小サムネイル作成
                $thumb_info = ImageUtility::createResizeImage(
                    $preview_tmp_name,
                    "./images/{$sub_dir}thumb_{$thumb_file_name}",
                    (int)$this->setting->getValue('thumb_width', 300),
                    (int)$this->setting->getValue('thumb_height', 300),
                    80
                );
                chmod("./images/{$sub_dir}thumb_{$thumb_file_name}", 0644);

                // メディアテーブルに情報を保存
                $original_id = $media->add(
                    $article_id,
                    'movie',
                    $new_file_name,
                    $sub_dir,
                    (int)$thumb_linfo['width'],
                    (int)$thumb_linfo['height'],
                    "thumb_l_{$thumb_file_name}",
                    (int)$thumb_linfo['width'],
                    (int)$thumb_linfo['height'],
                    "thumb_{$thumb_file_name}",
                    (int)$thumb_info['width'],
                    (int)$thumb_info['height'],
                    $file_info[$file->tmp_name]['contents'] ?? '',
                    (int)$file_info[$file->tmp_name]['sort'] ?? 0
                );

                $file_info[$file->tmp_name]['media_id'] = $original_id;
                unset($file_info[$file->tmp_name]);
                $image_idx++;
            }
        }

        // ソート順の更新およびコメント情報書き込み
        foreach ($file_info as $info) {
            $media->updateInfoById((int)$info['media_id'], (int)$info['sort'], $info['contents'] ?? '');
        }

        return ['index' => $image_idx, 'file_names' => $new_file_names];
    }

    /**
     * 指定されたIDの記事より1つ古い記事を取得する
     *
     * @param array $articleData 記事データ
     * @param int   $mode        モード
     *
     * @return array|null
     */
    public function getPrevArticleByData(array $article_data, $mode): ?array
    {
        return $this->getPrevOrNextArticleByData($article_data, $mode, 1);
    }

    /**
     * 指定されたIDの記事より1つ新しい記事を取得する
     *
     * @param array $articleData 記事データ
     * @param int   $mode        モード
     *
     * @return array|null
     */
    public function getNextArticleByData(array $article_data, $mode): ?array
    {
        return $this->getPrevOrNextArticleByData($article_data, $mode, 0);
    }

    /**
     * 指定されたIDの記事より1つ古い、または新しい記事を取得する
     *
     * @param array $articleData  記事データ
     * @param int   $mode         モード 0:常に空 1:すべての記事 2:タグ一致 3:プライマリ記事
     * @param int   $nextOrPrev   0:新しい記事, 1:古い記事
     *
     * @return array|null
     */
    private function getPrevOrNextArticleByData(array $article_data, $mode, $next_or_prev): ?array
    {
        if ($mode === 0) {
            return null;
        }

        $created_at = $article_data['created_at'];
        $tags = trim($article_data['tags']);

        if ($next_or_prev === 0) {
            $operator = '>';
            $order = 'asc';
        } else {
            $operator = '<';
            $order = 'desc';
        }

        $values = [];
        $query = "created_at {$operator} :created_at AND status = 0 AND hidden_at_list != 1 AND [type] = 0";

        if ($mode !== 1) {
            $search_tags = explode(' ', $tags);
            if (trim($tags) === "") {
                $query .= " AND tags = ''";
            } else if ($mode === 3) {
                // 先頭のタグのみ有効とする
                $search_tags = array_slice($search_tags, 0, 1);
                $query .= " AND tags LIKE :tag1 ESCAPE '!'";
                $values["tag1"] = " {$search_tags[0]} %";
            } else {
                foreach ($search_tags as $i => $word) {
                    $query .= " AND tags LIKE :tag{$i} ESCAPE '!'";
                    $values["tag{$i}"] = "% {$word} %";
                }
            }
        }

        $values['created_at'] = $created_at;

        $rows = $this->sql_crud->table('articles')
            ->where($query, $values)
            ->orderBy("created_at {$order}, id {$order}")
            ->fetch(1);

        foreach ($rows as $index => $row) {
            // メディア情報を取り込み
            $media = new Media($this->db, $this->setting);
            $media_rows = $media->getByArticleID($row['id']);
            $rows[$index]['media'] = $media_rows;
            $rows[$index]['media_count'] = count($media_rows);
        }

        return $rows[0] ?? null;
    }

    /**
     * 指定されたIDの記事を取得する。
     *
     * @param int  $id           記事ID
     * @param bool $includeMedia メディア情報を取得するか
     *
     * @return array|null
     */
    public function getById($id, bool $include_media = true): ?array
    {
        $row = $this->sql_crud->table('articles')->where('id = :id', ['id' => $id])->fetch();

        if (count($row) > 0) {
            if ($include_media) {
                $media = new Media($this->db, $this->setting);
                $media_rows = $media->getByArticleID($id);
                $row[0]['media'] = $media_rows;
                $row[0]['media_count'] = count($media_rows);
            }
            return $row[0];
        }

        return null;
    }

    /**
     * 編集中データを取得する
     *
     * @param int $id 記事ID
     *
     * @return array|null
     */
    public function getEditingById($id): ?array
    {
        $stmt = $this->db->prepare('SELECT * FROM article_editings WHERE article_id = :id');
        $stmt->bindValue(':id', $id, \PDO::PARAM_INT);
        $stmt->execute();
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        return $row ?: null;
    }

    /**
     * 指定されたURLの記事を取得する。
     *
     * @param string $url           URL。https://example.com/(この部分)
     * @param bool   $includeMedia  メディア情報を取得するか
     *
     * @return array|null 取得した記事情報の配列。対象が存在しない場合はNULL。
     *
     */
    public function getByAltURL(string $url, bool $include_media = true): ?array
    {
        $stmt = $this->db->prepare('SELECT * FROM articles WHERE alt_url = :alt_url');
        $stmt->bindValue(':alt_url', trim($url, '/'), \PDO::PARAM_STR);
        $stmt->execute();
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        if ($row) {
            if ($include_media) {
                $media = new Media($this->db, $this->setting);
                $media_rows = $media->getByArticleID($row['id']);
                $row['media'] = $media_rows;
                $row['media_count'] = count($media_rows);
            }
            return $row;
        }

        return null;
    }

    private function createSortClause(array $sorts): string
    {
        $sort = '';
        foreach ($sorts as &$sort_item) {
            [$sort_field, $sort_order] = explode(' ', trim($sort_item) . ' ');
            $sort_order = strtolower($sort_order) === 'asc' ? 'asc' : 'desc';
            if ($this->checkFieldExists($sort_field) === false) {
                throw new InvalidArgumentException();
            }
            $sort .= "[$sort_field] {$sort_order},";
        }
        return rtrim($sort, ',');
    }
    private function checkFieldExists(string $field): bool
    {
        foreach ($this->field_info as [$f, $default, $is_bool]) {
            if ($f === $field) {
                return true;
            }
        }
        return false;
    }

    private function getSpecialPage($type, bool $include_media = true): ?array
    {
        $stmt = $this->db->prepare("SELECT * FROM articles WHERE [type] = $type");
        $stmt->execute();
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        if ($row) {
            if ($include_media) {
                $media = new Media($this->db, $this->setting);
                $media_rows = $media->getByArticleID($row['id']);
                $row['media'] = $media_rows;
                $row['media_count'] = count($media_rows);
            }
            return $row;
        }

        return [
            'created_at' => '1980-01-01',
            'contents' => '',
            'hidden' => 1,
            'style' => '99',
            'type' => $type,
            'media' => []
        ];
    }

    public function getIndexPage(bool $include_media = true): ?array
    {
        return $this->getSpecialPage(2);
    }
    public function getEntryPage(bool $include_media = true): ?array
    {
        return $this->getSpecialPage(3);
    }
    public function getNSFWPage(bool $include_media = true): ?array
    {
        return $this->getSpecialPage(4);
    }


    /**
     * 指定されたタグを持った記事一覧を取得する。
     *
     * @param string|array $tags            タグのリスト。スペース区切りまたは配列。
     * @param bool         $showHidden      非表示記事を対象とするか
     * @param array        $sort            並び替え条件。["created_at desc", "id desc"]等Field＋Asc/Descの組み合わせで指定
     * @param int          $recordsPerPage  1ページ当たりの取得件数
     * @param int          $pageIndex       取得するページ番号
     *
     * @return array
     */
    public function searchByTagWithCount(
        $tags,
        bool $show_hidden,
        array $sorts = ['created_at desc', 'id desc'],
        int $records_per_page = 9999,
        int $page_index = 1
    ): array {

        $sort = $this->createSortClause($sorts); //検索条件構築
        $query = '[type] = 0';
        if ($tags !== '') {
            $search_tags = is_array($tags) ? $tags : explode(' ', trim($tags, ' '));
            foreach ($search_tags as $i => $word) {
                $query .= " AND (tags LIKE :tag{$i} ESCAPE '!' OR tags LIKE :hidden_tag{$i} ESCAPE '!')";
            }
        } else {
            $search_tags = [];
        }

        if (!$show_hidden) {
            $query .= ' AND hidden_at_list = false and status = 0 ';
        }

        // 総件数を取得
        $count_query = "SELECT COUNT(*) AS count FROM articles WHERE {$query}";
        $stmt = $this->db->prepare($count_query);
        foreach ($search_tags as $i => $word) {
            $escaped_word = SQLiteCRUD::LikeEscape($word);
            $stmt->bindValue(":tag{$i}", "% {$escaped_word} %", \PDO::PARAM_STR);
            $stmt->bindValue(":hidden_tag{$i}", "% -{$escaped_word} %", \PDO::PARAM_STR);
        }

        $stmt->execute();
        $count_row = $stmt->fetch();
        $total_count = (int)($count_row['count'] ?? 0);

        // 実体を取得
        $limit = '';
        $offset = null;
        if ($records_per_page !== 9999 || $page_index !== 1) {
            $offset = ($page_index - 1) * $records_per_page;
            $limit = ' LIMIT :limit OFFSET :offset';
        }

        $data_query = "SELECT * FROM articles WHERE {$query} ORDER BY {$sort}";

        if ($limit !== '') {
            $data_query .= $limit;
        }

        $stmt = $this->db->prepare($data_query);

        foreach ($search_tags as $i => $word) {
            $escaped_word = SQLiteCRUD::LikeEscape($word);
            $stmt->bindValue(":tag{$i}", "% {$escaped_word} %", \PDO::PARAM_STR);
            $stmt->bindValue(":hidden_tag{$i}", "% -{$escaped_word} %", \PDO::PARAM_STR);
        }

        if ($limit !== '') {
            $stmt->bindValue(':limit', $records_per_page, \PDO::PARAM_INT);
            $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
        }

        $stmt->execute();
        $rows = $stmt->fetchAll();

        foreach ($rows as $index => $row) {
            $media = new Media($this->db, $this->setting);
            $media_rows = $media->getByArticleID($row['id']);
            $rows[$index]['media'] = $media_rows;
            $rows[$index]['media_count'] = count($media_rows);
        }

        return ['articles' => $rows, 'count' => $total_count];
    }

    /**
     * 指定された条件で記事を検索し、取得する。
     *
     * @param string $query           検索条件。"title LIKE :title"のように条件を記述する。
     * @param array  $conditions      検索条件の値。["title" => "日記"] のような形でキー:条件を格納する。
     * @param array  $sorts           ソート順序。["created_at desc", "id asc"]等の配列で指定する。
     * @param int    $recordsPerPage 1ページ当たりの取得件数
     * @param int    $pageIndex       ページ番号
     *
     * @return array
     */
    public function search(
        string $query,
        array $conditions,
        array $sorts = ['created_at desc', 'id desc'],
        int $records_per_page = 9999,
        int $page_index = 1
    ): array {
        $offset = null;
        $limit = null;
        if ($records_per_page !== 9999 || $page_index > 1) {
            $offset = ($page_index - 1) * $records_per_page;
            $limit = $records_per_page;
        }
        $sort = $this->createSortClause($sorts); //検索条件構築

        $rows = $this->sql_crud->table('articles')
            ->where($query, $conditions)
            ->orderBy($sort)
            ->fetch($limit, $offset);

        foreach ($rows as $index => $row) {
            $media = new Media($this->db, $this->setting);
            $media_rows = $media->getByArticleID($row['id']);
            $rows[$index]['media'] = $media_rows;
            $rows[$index]['media_count'] = count($media_rows);
        }

        return $rows;
    }

    /**
     * 指定された条件で記事を検索し、件数を取得する。
     *
     * @param string $query      検索条件。
     * @param array  $conditions 検索条件の値。
     *
     * @return int
     */
    public function getCount(string $query, array $conditions): int
    {
        return $this->sql_crud->table('articles')->where($query, $conditions)->count();
    }

    /**
     * 指定された条件で記事を検索し、取得する。合わせて、該当する記事数を返す。
     *
     * @param string $query           検索条件。
     * @param array  $conditions      検索条件の値。
     * @param array  $sorts           ソート順序。["created_at desc", "id asc"]等の配列で指定する。
     * @param int    $recordsPerPage  1ページ当たりの取得件数。
     * @param int    $page            ページ番号。
     *
     * @return array ["articles" => array, "count" => int]
     */
    public function searchWithTotalCount(
        string $query,
        array $conditions,
        array $sorts = ['created_at desc', 'id desc'],
        int $records_per_page = 9999,
        int $page = 1
    ): array {
        return [
            'articles' => $this->search($query, $conditions, $sorts, $records_per_page, $page),
            'count' => $this->getCount($query, $conditions)
        ];
    }

    /**
     * 指定された削除キーが指定されたIDの記事と合致するか確認する
     *
     * @param int    $id  記事ID
     * @param string $key 削除キー（平文）
     *
     * @return bool 一致していればtrue, 一致していない場合または記事がない場合はfalse。キーが空の場合は常にfalse。
     *
     */
    public function validateDeleteKey($id, string $key): bool
    {
        if (trim($key) === '') {
            return false;
        }

        $stmt = $this->db->prepare('SELECT delete_key FROM articles WHERE id = :id');
        $stmt->bindValue(':id', $id, \PDO::PARAM_INT);
        $stmt->execute();
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        return $row && password_verify($key, $row['delete_key']);
    }

    public function countUpViews($id): void
    {
        $this->sql_crud->rawQuery('UPDATE articles SET views = views + 1 WHERE id = :id', ['id' => $id]);
    }
}
