<?php

namespace app\foreignone;

use DateTime;
use app\Admin;
use app\MarkupUtil;
use engine\exceptions\ParseFormException;
use engine\http\AjaxError;
use engine\http\AjaxOk;
use engine\http\errors\Forbidden;
use engine\http\errors\NotFound;
use engine\http\errors\PermanentRedirect;
use engine\http\errors\Redirect;
use engine\http\Response;

class AdminHandler
    extends BaseHandler
{
    public function __construct() {
        parent::__construct();
        $this->skin->addStatic('css/admin.css', 'js/admin.js');
        $this->skin->exportStrings(['error']);
        $this->skin->options->insideAdminInterface = true;
    }

    protected function beforeDispatch(string $http_method, string $action) {
        if ($action != 'login' && !isAdmin())
            throw new Forbidden();
    }   

    public function GET_index(): Response {
        $this->skin->title = lang('admin_title');
        return $this->skin->renderPage('admin_index.twig', [
            'admin_login' => Admin::getLogin(),
            'logout_token' => $this->getCSRF('logout'),
        ]);
    }

    public function GET_login(): Response {
        if (isAdmin())
            throw new Redirect('/admin/');
        $this->skin->title = lang('admin_title');
        return $this->skin->renderPage('admin_login.twig', [
            'form_token' => $this->getCSRF('adminlogin'),
        ]);
    }

    public function POST_login(): Response {
        $this->checkCSRF('adminlogin');
        list($login, $password) = $this->input('login, password');
        if (Admin::auth($login, $password))
            throw new PermanentRedirect('/admin/');
        throw new Forbidden('');
    }

    public function GET_logout(): Response {
        $this->checkCSRF('logout');
        Admin::logout();
        throw new Redirect('/admin/login/');
    }

    public function GET_errors(): Response {
        list($ip, $query, $url_query, $file_query, $line_query, $per_page)
            = $this->input('i:ip, query, url_query, file_query, i:line_query, i:per_page');

        if (!$per_page)
            $per_page = 100;
        $db = getDB();

        $query = trim($query ?? '');
        $url_query = trim($url_query ?? '');

        $sql_where = [];
        if ($ip) {
            $sql_where[] = "ip='".$db->escape($ip)."'";
        }
        if ($query) {
            $sql_where[] = "text LIKE '%".$db->escape($query)."%'";
        }
        if ($url_query) {
            $sql_where[] = "url LIKE '%".$db->escape($url_query)."%'";
        }
        if ($file_query) {
            $sql_where[] = "file LIKE '%".$db->escape($file_query)."%'";
        }
        if ($line_query) {
            $sql_where[] = "line='".$db->escape($line_query)."'";
        }

        if (!empty($sql_where)) {
            $sql_where = " WHERE ".implode(' AND ', $sql_where)." ";
        } else {
            $sql_where = '';
        }

        $count = (int)$db->result($db->query("SELECT COUNT(*) FROM backend_errors".$sql_where));
        list($page, $pages, $offset) = $this->getPage($per_page, $count);

        $q = $db->query("SELECT *, INET_NTOA(ip) ip_s FROM backend_errors $sql_where ORDER BY id DESC LIMIT $offset, $per_page");

        $list = [];

        while ($row = $db->fetch($q)) {
            $row['date'] = formatTime($row['ts'], [
                'seconds' => true,
                'short_months' => true,
            ]);
            $row['full_url'] = !str_starts_with($row['url'], 'https://') ? 'https://'.$row['url'] : $row['url'];
            $error_name = \engine\logging\Util::getPHPErrorName((int)$row['errno']);
            if (!is_null($error_name))
                $row['errtype'] = $error_name;
            $list[] = $row;
        }

        // url for pageNav
        $url = '/admin/errors/';
        $url_params = [];
        if ($ip) {
            $url_params['ip'] = $ip;
        }
        if ($query) {
            $url_params['query'] = $query;
        }
        if ($url_query) {
            $url_params['url_query'] = $url_query;
        }
        if ($file_query) {
            $url_params['file_query'] = $file_query;
        }
        if ($line_query) {
            $url_params['line_query'] = $line_query;
        }

        if (!empty($url_params)) {
            $url .= '?'.http_build_query($url_params).'&';
        } else {
            $url .= '?';
        }

        $vars = [
            'list' => $list,
            'count' => $count,
            'pn_page' => $page,
            'pn_pages' => $pages,
            'url' => $url,
        ];

        if ($ip) {
            $vars += [
                'ip_filter' => ulong2ip($ip),
                'ip' => $ip
            ];
        }

        $query_var_names = ['query', 'url_query', 'file_query', 'line_query'];
        foreach ($query_var_names as $query_var_name) {
            if ($$query_var_name)
                $vars += [$query_var_name => $$query_var_name];
        }

        $this->skin->options->wide = true;
        $this->skin->title = lang('admin_errors');
        return $this->skin->renderPage('admin_errors.twig', $vars);
    }

    public function GET_auth_log(): Response {
        $db = getDB();
        $count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log"));
        $per_page = 100;
        list($page, $pages, $offset) = $this->getPage($per_page, $count);

        $q = $db->query("SELECT *, 
                                INET_NTOA(ip) AS ip,
                                admins.login AS login,
                                admins.activity_ts AS activitiy_ts
                        FROM admin_log 
                        LEFT JOIN admins ON admins.id=admin_log.admin_id
                        ORDER BY admin_log.id DESC
                        LIMIT $offset, ".$per_page);
        $list = $db->fetchAll($q);

        if (!empty($list)) {
            $list = array_map(function($item) {
                $item['date'] = formatTime($item['ts']);
                $item['activity_ts_s'] = formatTime($item['activity_ts']);
                return $item;
            }, $list);
        }

        $this->skin->options->wide = true;
        $this->skin->title = lang('admin_auth_log');
        $this->skin->set([
            'list' => $list,
            'pn_page' => $page,
            'pn_pages' => $pages
        ]);
        return $this->skin->renderPage('admin_auth_log.twig');
    }

    public function GET_actions_log(): Response {
        $field_types = \app\AdminActions\Util\Logger::getFieldTypes();
        foreach ($field_types as $type_prefix => $type_data) {
            for ($i = 1; $i <= $type_data['count']; $i++) {
                $name = $type_prefix.'arg'.$i;
                if (isset($_REQUEST[$name]) && (string)$_REQUEST[$name] !== '')
                    $argument_filters[$name] = $type_data['unpacker']((string)$_REQUEST[$name]);
            }
        }

        $per_page = 100;

        $count = \app\AdminActions\Util\Logger::getRecordsCount();
        list($page, $pages, $offset) = $this->getPage($per_page, $count);

        $admin_ids = [];
        $admin_logins = [];

        $records = \app\AdminActions\Util\Logger::getRecords($offset, $per_page);
        foreach ($records as $record) {
            list($admin_id) = $record->getActorInfo();
            if ($admin_id !== null)
                $admin_ids[$admin_id] = true;
        }

        if (!empty($admin_ids))
            $admin_logins = Admin::getLoginsById(array_keys($admin_ids));

        $url = '/admin/actions-log/?';

        $filter_fields = [];
        foreach ($field_types as $type_prefix => $type_data) {
            for ($i = 1; $i <= $type_data['count']; $i++) {
                $name = $type_prefix.'arg'.$i;
                $filter_fields[$name] = $argument_filters[$name] ?? '';
            }
        }

        $this->skin->options->wide = true;
        $this->skin->title = lang('admin_actions_log');
        return $this->skin->renderPage('admin_actions_log.twig', [
            'list' => $records,
            'pn_page' => $page,
            'pn_pages' => $pages,
            'admin_logins' => $admin_logins,
            'url' => $url,
            'action_types' => \app\AdminActions\Util\Logger::getActions(true),
        ]);
    }

    public function GET_uploads(): Response {
        list($error) = $this->input('error');
        $uploads = Upload::getAllUploads();

        $this->skin->title = lang('blog_upload');
        return $this->skin->renderPage('admin_uploads.twig', [
            'error' => $error,
            'uploads' => $uploads,
            'langs' => PostLanguage::casesAsStrings(),
            'form_token' => $this->getCSRF('add_upload'),
        ]);
    }

    public function POST_uploads(): Response {
        $this->checkCSRF('add_upload');
        list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru');

        if (!isset($_FILES['files']))
            throw new Redirect('/admin/uploads/?error='.urlencode('no file'));

        $files = [];
        for ($i = 0; $i < count($_FILES['files']['name']); $i++) {
            $files[] = [
                'name' => $_FILES['files']['name'][$i],
                'type' => $_FILES['files']['type'][$i],
                'tmp_name' => $_FILES['files']['tmp_name'][$i],
                'error' => $_FILES['files']['error'][$i],
                'size' => $_FILES['files']['size'][$i],
            ];
        }

        if (count($files) > 1) {
            $note_en = '';
            $note_ru = '';
            $custom_name = '';
        }

        foreach ($files as $f) {
            if ($f['error'])
                throw new Redirect('/admin/uploads/?error='.urlencode('error code '.$f['error']));

            if (!$f['size'])
                throw new Redirect('/admin/uploads/?error='.urlencode('received empty file'));

            $ext = extension($f['name']);
            if (!Upload::isExtensionAllowed($ext))
                throw new Redirect('/admin/uploads/?error='.urlencode('extension not allowed'));

            $name = $custom_name ?: $f['name'];
            $upload_id = Upload::add(
                $f['tmp_name'],
                $name,
                $note_en,
                $note_ru);

            if (!$upload_id)
                throw new Redirect('/admin/uploads/?error='.urlencode('failed to create upload'));

            Admin::logAction(new \app\AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru));
        }

        throw new Redirect('/admin/uploads/');
    }

    public function GET_upload_delete(): Response {
        list($id) = $this->input('i:id');
        $upload = Upload::get($id);
        if (!$upload)
            throw new Redirect('/admin/uploads/?error='.urlencode('upload not found'));
        $this->checkCSRF('delupl'.$id);
        Upload::delete($id);
        Admin::logAction(new \app\AdminActions\UploadsDelete($id));
        throw new Redirect('/admin/uploads/');
    }

    public function POST_upload_edit_note(): Response {
        list($id, $note, $lang) = $this->input('i:id, note, lang');
        $lang = PostLanguage::tryFrom($lang);
        if (!$lang)
            throw new NotFound();

        $upload = Upload::get($id);
        if (!$upload)
            throw new Redirect('/admin/uploads/?error='.urlencode('upload not found'));

        $this->checkCSRF('editupl'.$id);

        $upload->setNote($lang, $note);
        $texts = PostText::getTextsWithUpload($upload);
        if (!empty($texts)) {
            foreach ($texts as $text) {
                $text->updateHtml();
                $text->updateText();
            }
        }

        Admin::logAction(new \app\AdminActions\UploadsEditNote($id, $note, $lang->value));
        throw new Redirect('/admin/uploads/');
    }

    public function POST_ajax_md_preview(): Response {
        $this->ensureIsXHR();
        list($md, $title, $use_image_previews, $lang, $is_page) = $this->input('md, title, b:use_image_previews, lang, b:is_page');
        $lang = PostLanguage::tryFrom($lang);
        if (!$lang)
            $lang = PostLanguage::getDefault();
        if ($is_page) {
            $md = '# '.$title."\n\n".$md;
            $title = '';
        }
        $html = MarkupUtil::markdownToHtml($md, $use_image_previews, $lang);
        $html = $this->skin->render('markdown_preview.twig', [
            'unsafe_html' => $html,
            'title' => $title
        ]);
        return new AjaxOk(['html' => $html]);
    }

    public function GET_page_add(): Response {
        list($name) = $this->input('short_name');
        $page = Page::getByName($name);
        if ($page)
            throw new Redirect($page->getUrl());

        $this->skin->exportStrings('/^(err_)?pages_/');
        $this->skin->exportStrings('/^(err_)?blog_/');
        $this->skin->title = lang('pages_create_title', $name);
        $this->setWidePageOptions();

        $js_params = [
            'pages' => true,
            'edit' => false,
            'token' => $this->getCSRF('addpage'),
            'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
        ];

        return $this->skin->renderPage('admin_page_form.twig', [
            'is_edit' => false,
            'short_name' => $name,
            'title' => '',
            'text' => '',
            'langs' => PostLanguage::cases(),
            'js_params' => $js_params,
            'form_url' => '/'.$name.'/create/'
        ]);
    }

    public function POST_page_add(): Response {
        $this->checkCSRF('addpage');

        list($name, $text, $title) = $this->input('short_name, text, title');
        $page = Page::getByName($name);
        if ($page)
            throw new NotFound();

        $error_code = null;

        if (!$title) {
            $error_code = 'no_title';
        } else if (!$text) {
            $error_code = 'no_text';
        }

        if ($error_code)
            return new AjaxError(['code' => $error_code]);

        if (!Page::add([
            'short_name' => $name,
            'title' => $title,
            'md' => $text
        ])) {
            return new AjaxError(['code' => 'db_err']);
        }

        Admin::logAction(new \app\AdminActions\PageCreate($name));

        $page = Page::getByName($name);
        return new AjaxOk(['url' => $page->getUrl()]);
    }

    public function GET_page_delete(): Response {
        list($name) = $this->input('short_name');

        $page = Page::getByName($name);
        if (!$page)
            throw new NotFound();

        $url = $page->getUrl();

        $this->checkCSRF('delpage'.$page->shortName);
        Page::delete($page);
        Admin::logAction(new \app\AdminActions\PageDelete($name));
        throw new Redirect($url);
    }

    public function GET_page_edit(): Response {
        list($short_name, $saved) = $this->input('short_name, b:saved');

        $page = Page::getByName($short_name);
        if (!$page)
            throw new NotFound();

        $this->skin->exportStrings('/^(err_)?pages_/');
        $this->skin->exportStrings('/^(err_)?blog_/');
        $this->skin->title = lang('pages_page_edit_title', $page->shortName);
        $this->setWidePageOptions();
        
        $parent = '';
        if ($page->parentId) {
            $parent_page = Page::getById($page->parentId);
            if ($parent_page)
                $parent = $parent_page->shortName;
        }

        $js_params = [
            'pages' => true,
            'edit' => true,
            'token' => $this->getCSRF('editpage'.$short_name),
            'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
            'text' => [
                'text' => $page->md,
                'title' => $page->title,
            ]
        ];

        return $this->skin->renderPage('admin_page_form.twig', [
            'is_edit' => true,
            'short_name' => $page->shortName,
            'title' => $page->title,
            'text' => $page->md,
            'visible' => $page->visible,
            'render_title' => $page->renderTitle,
            'parent' => $parent,
            'saved' => $saved,
            'langs' => PostLanguage::cases(),
            'js_params' => $js_params,
        ]);
    }

    public function POST_page_edit(): Response {
        $this->ensureIsXHR();
        list($short_name) = $this->input('short_name');

        $page = Page::getByName($short_name);
        if (!$page)
            throw new NotFound();

        $this->checkCSRF('editpage'.$page->shortName);

        list($text, $title, $visible, $short_name, $parent, $render_title)
            = $this->input('text, title, b:visible, new_short_name, parent, b:render_title');

        $text = trim($text);
        $title = trim($title);
        $error_code = null;

        if (!$title) {
            $error_code = 'no_title';
        } else if (!$text) {
            $error_code = 'no_text';
        } else if (!$short_name) {
            $error_code = 'no_short_name';
        }

        if ($error_code)
            return new AjaxError(['code' => $error_code]);

        $new_short_name = $page->shortName != $short_name ? $short_name : null;
        $parent_page = Page::getByName($parent);
        $parent_id = $parent_page ? $parent_page->id : 0;

        PreviousText::add(PreviousText::TYPE_PAGE, $page->get_id(), $page->md, $page->updateTs ?: $page->ts);
        $page->edit([
            'title' => $title,
            'md' => $text,
            'visible' => (bool)$visible,
            'short_name' => $short_name,
            'render_title' => (bool)$render_title,
            'parent_id' => $parent_id
        ]);

        Admin::logAction(new \app\AdminActions\PageEdit($short_name, $new_short_name));
        return new AjaxOk(['url' => $page->getUrl().'edit/?saved=1']);
    }

    public function GET_post_add(): Response {
        $this->skin->exportStrings('/^(err_)?blog_/');
        $this->skin->title = lang('blog_write');
        $this->setWidePageOptions();

        $js_texts = [];
        foreach (PostLanguage::cases() as $pl) {
            $js_texts[$pl->value] = [
                'title' => '',
                'md' => '',
                'toc' => false,
                'keywords' => '',
            ];
        }

        $js_params = [
            'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
            'token' => $this->getCSRF('post_add')
        ];
        $form_url = '/articles/write/';

        $bc = [
            ['url' => '/articles/?lang='.PostLanguage::getDefault()->value, 'text' => lang('articles')],
            ['text' => lang('blog_new_post')]
        ];

        return $this->skin->renderPage('admin_post_form.twig', [
            'title' => '',
            'text' => '',
            'short_name' => '',
            'source_url' => '',
            'keywords' => '',
            'date' => '',

            'bc' => $bc,
            'js_params' => $js_params,
            'form_url' => $form_url,
            'langs' => PostLanguage::casesAsStrings(),
            'lang' => PostLanguage::getDefault()->value
        ]);
    }

    public function POST_post_add(): Response {
        $this->ensureIsXHR();
        $this->checkCSRF('post_add');

        list($visibility_enabled, $short_name, $langs, $date)
            = $this->input('b:visible, short_name, langs, date');

        try {
            self::_postEditValidateCommonData($date);
        } catch (ParseFormException $e) {
            return new AjaxError(['code' => $e->getCode()]);
        }

        $lang_data = [];
        $at_least_one_lang_is_written = false;
        foreach (PostLanguage::cases() as $lang) {
            list($title, $text, $keywords, $toc_enabled) = $this->input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", trim: true);
            if ($title !== '' && $text !== '') {
                $lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled];
                $at_least_one_lang_is_written = true;
            }
        }

        $error_code = null;
        if (!$at_least_one_lang_is_written) {
            $error_code = 'no_text';
        } else if (empty($short_name)) {
            $error_code = 'no_short_name';
        }
        if ($error_code)
            return new AjaxError(['code' => $error_code]);

        $post = Post::add([
            'visible' => $visibility_enabled,
            'short_name' => $short_name,
            'date' => $date,
            'source_url' => ''
        ]);

        if (!$post)
            return new AjaxError(['code' => 'db_err', 'message' => 'failed to add post']);

        // add texts
        $added_texts = []; // for admin actions logging, at the end
        foreach ($lang_data as $lang => $data) {
            list($title, $text, $keywords, $toc_enabled) = $data;
            if (!($new_post_text = $post->addText(
                    lang: PostLanguage::from($lang),
                    title: $title,
                    md: $text,
                    keywords: $keywords,
                    toc: $toc_enabled))
            ) {
                Post::delete($post);
                return new AjaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
            } else {
                $added_texts[] = [$new_post_text->id, $lang];
            }
        }

        Admin::logAction(new \app\AdminActions\PostCreate($post->id));
        foreach ($added_texts as $added_text) {
            list($id, $lang) = $added_text;
            Admin::logAction(new \app\AdminActions\PostTextCreate($id, $post->id, $lang));
        }

        // done
        return new AjaxOk(['url' => $post->getUrl()]);
    }

    public function GET_post_delete(): Response {
        list($name) = $this->input('short_name');

        $post = Post::getByName($name);
        if (!$post)
            throw new NotFound();

        $id = $post->id;
        $this->checkCSRF('delpost'.$id);
        Post::delete($post);
        Admin::logAction(new \app\AdminActions\PostDelete($id));
        throw new Redirect('/articles/');
    }

    public function GET_post_edit(): Response {
        list($short_name, $saved, $lang) = $this->input('short_name, b:saved, lang');
        $lang = PostLanguage::from($lang);

        $post = Post::getByName($short_name);
        if (!$post)
            throw new NotFound();

        $texts = $post->getTexts();
        if (!isset($texts[$lang->value]))
            throw new NotFound();

        $js_texts = [];
        foreach (PostLanguage::cases() as $pl) {
            if (isset($texts[$pl->value])) {
                $text = $texts[$pl->value];
                $js_texts[$pl->value] = [
                    'title' => $text->title,
                    'md' => $text->md,
                    'toc' => $text->toc,
                    'keywords' => $text->keywords,
                ];
            } else {
                $js_texts[$pl->value] = [
                    'title' => '',
                    'md' => '',
                    'toc' => false,
                    'keywords' => '',
                ];
            }
        }

        $text = $texts[$lang->value];

        $this->skin->exportStrings('/^(err_)?blog_/');
        $this->skin->exportStrings(['blog_post_edit_title']);
        $this->skin->title = lang('blog_post_edit_title', $text->title);
        $this->setWidePageOptions();

        $bc = [
            ['url' => '/articles/?lang='.$text->lang->value, 'text' => lang('articles')],
            ['url' => $post->getUrl().'?lang='.$text->lang->value, 'text' => lang('blog_view_post')]
        ];

        $js_params = [
            'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
            'token' => $this->getCSRF('editpost'.$post->id),
            'edit' => true,
            'id' => $post->id,
            'texts' => $js_texts
        ];
        $form_url = $post->getUrl().'edit/';

        return $this->skin->renderPage('admin_post_form.twig', [
            'is_edit' => true,
            'post' => $post,
            'title' => $text->title,
            'text' => $text->md,
            'date' => $post->getDateForInputField(),
            'visible' => $post->visible,
            'toc' => $text->toc,
            'short_name' => $short_name,
            'source_url' => $post->sourceUrl,
            'keywords' => $text->keywords,
            'saved' => $saved,
            'langs' => PostLanguage::casesAsStrings(),
            'lang' => $text->lang->value,
            'js_params' => $js_params,
            'form_url' => $form_url,
            'bc' => $bc
        ]);
    }

    public function POST_post_edit(): Response {
        $this->ensureIsXHR();
        list($old_short_name, $short_name, $langs, $date, $source_url) = $this->input('short_name, new_short_name, langs, date, source_url');

        $post = Post::getByName($old_short_name);
        if (!$post)
            throw new NotFound();

        $this->checkCSRF('editpost'.$post->id);

        try {
            self::_postEditValidateCommonData($date);
        } catch (ParseFormException $e) {
            return new AjaxError(['code' => $e->getCode()]);
        }

        if (empty($short_name))
            return new AjaxError(['code' => 'no_short_name']);

        foreach (explode(',', $langs) as $lang) {
            $lang = PostLanguage::from($lang);
            list($text, $title, $visible, $toc, $keywords) = $this->input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}, keywords:{$lang->value}");

            $error_code = null;
            if (!$title)
                $error_code = 'no_title';
            else if (!$text)
                $error_code = 'no_text';
            if ($error_code)
                return new AjaxError(['code' => $error_code]);

            $pt = $post->getText($lang);
            if (!$pt) {
                $pt = $post->addText(
                    lang: $lang,
                    title: $title,
                    md: $text,
                    keywords: $keywords,
                    toc: $toc
                );
                if (!$pt)
                    return new AjaxError(['code' => 'db_err']);
            } else {
                PreviousText::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp());
                $pt->edit([
                    'title' => $title,
                    'md' => $text,
                    'toc' => (int)$toc,
                    'keywords' => $keywords
                ]);
            }
        }

        $post_data = [
            'date' => $date,
            'visible' => $visible,
            'source_url' => $source_url
        ];
        if ($post->shortName != $short_name)
            $post_data['short_name'] = $short_name;
        $post->edit($post_data);

        Admin::logAction(new \app\AdminActions\PostEdit($post->id));
        return new AjaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
    }

    public function GET_books(): Response {
        $this->skin->title = lang('admin_books');
        return $this->skin->renderPage('admin_books.twig');
    }

    protected static function _postEditValidateCommonData($date) {
        $dt = DateTime::createFromFormat("Y-m-d", $date);
        $date_is_valid = $dt && $dt->format("Y-m-d") === $date;
        if (!$date_is_valid)
            throw new ParseFormException(code: 'no_date');
    }

    protected function setWidePageOptions(): void {
        $this->skin->options->fullWidth = true;
        $this->skin->options->noFooter = true;
    }
}