<?php

namespace Acms\Services\Installer;

use InvalidArgumentException;

/**
 * Installer 実行オプション (不変 DTO)
 *
 * 「空 DB → 使える状態」に必要な情報をまとめる:
 *   - DB 接続情報 (config.server.php に書き込むための値)
 *   - どの bin を投入するか (binName + 関連 bin 自動検出)
 *   - 最初の blog の名前 / code / domain
 *   - admin user の code / name / password / mail
 */
final class InstallerOptions
{
    /**
     * `Acms\Services\Blog\Helper::isDomain()` の形式チェック部分 (ライセンス判定・DB 参照を除く)
     * と同じ正規表現。ドメイン形式 / IPv4 アドレス / localhost のいずれかを許可する。
     */
    private const DOMAIN_FORMAT_PATTERN = '@^([a-z0-9]+[a-z0-9-]*(?<=[a-z0-9])\.)+[a-z]+[a-z0-9-]*(?<=[a-z0-9])\.?$|'
        . '^(?:\d{1,3}\.){3}\d{1,3}$|'
        . '^localhost$@';

    public function __construct(
        public readonly string $binName,
        public readonly string $adminUserCode,
        public readonly string $adminUserName,
        public readonly string $adminUserPassword,
        public readonly string $adminUserMail,
        public readonly ?string $blogName = null,
        public readonly ?string $blogCode = null,
        public readonly ?string $blogDomain = null,
        public readonly bool $rootOnly = false,
        public readonly bool $skipCacheFlush = false,
        public readonly bool $dryRun = false,
        // DB 接続情報 (config.server.php へ書き込む。全部 null なら既存の config をそのまま使う)
        public readonly ?string $dbHost = null,
        public readonly ?string $dbName = null,
        public readonly ?string $dbUser = null,
        public readonly ?string $dbPassword = null,
        public readonly ?string $dbPrefix = null,
        public readonly ?int $dbPort = null,
        public readonly ?string $dbCharset = null,
    ) {
        $this->validate();
    }

    private function validate(): void
    {
        if ($this->binName === '') {
            throw new InvalidArgumentException('binName is required');
        }
        if (($error = self::validateAdminUserCode($this->adminUserCode)) !== null) {
            throw new InvalidArgumentException('adminUserCode: ' . $error);
        }
        if (($error = self::validateAdminUserName($this->adminUserName)) !== null) {
            throw new InvalidArgumentException('adminUserName: ' . $error);
        }
        if ($this->adminUserPassword === '') {
            throw new InvalidArgumentException('adminUserPassword is required');
        }
        if (($error = self::validateAdminUserMail($this->adminUserMail)) !== null) {
            throw new InvalidArgumentException('adminUserMail: ' . $error);
        }
        if (($error = self::validateBlogName($this->blogName)) !== null) {
            throw new InvalidArgumentException('blogName: ' . $error);
        }
        if (($error = self::validateBlogCode($this->blogCode)) !== null) {
            throw new InvalidArgumentException('blogCode: ' . $error);
        }
        if (($error = self::validateBlogDomain($this->blogDomain)) !== null) {
            throw new InvalidArgumentException('blogDomain: ' . $error);
        }
    }

    /**
     * 実際に使う blog ドメインを返す。
     *
     * `blogDomain` 指定が最優先。未指定 (null/空文字) のときは `$fallback` を採用する。
     * Installer の呼び出し側が config.server.php の DOMAIN 定数を `$fallback` に渡す。
     *
     * Web インストーラ (setup/install.php) は `blogDomain: DOMAIN` を直接渡すが、
     * CLI 経由ではユーザが `--blog-domain` を毎回指定しない運用が想定されるため、
     * フォールバックを DTO 側で吸収しておくと CLI / Web どちらでも同じ振る舞いになる。
     */
    public function effectiveBlogDomain(?string $fallback = null): string
    {
        if ($this->blogDomain !== null && $this->blogDomain !== '') {
            return $this->blogDomain;
        }
        if ($fallback !== null && $fallback !== '') {
            return $fallback;
        }
        return '';
    }

    /**
     * 管理者ログイン ID (user_code) のバリデーション。
     *
     * 管理画面の `ACMS_POST_User_Insert` と同等の規則:
     *   - 必須 / 64 文字以下 / 英数字・ハイフン・アンダースコア のみ
     *   - さらに `isValidCode()` (危険なパス記号や制御文字を弾く防御) を通す
     *
     * 戻り値: 妥当なら `null`、不正ならエラーメッセージ (1 行)。
     * CLI の対話プロンプトと、非対話ルートの validate() の両方から再利用する。
     */
    public static function validateAdminUserCode(string $code): ?string
    {
        if ($code === '') {
            return 'is required';
        }
        if (strlen($code) > 64) {
            return 'must be 64 characters or fewer';
        }
        // 管理画面と同じ REGEX_VALID_ID = '@^[a-zA-Z0-9_-]+$@'
        if (!preg_match('@^[a-zA-Z0-9_-]+$@', $code)) {
            return 'may only contain letters, digits, "-" and "_"';
        }
        // 管理画面の `ACMS_POST_User_Insert` と同様、isValidCode() の防御も通す。
        // (上の正規表現を通っていればここで弾かれることはほぼ無いが、関数の挙動が
        //  将来変わったときの保険として明示的に呼ぶ)
        if (function_exists('isValidCode') && !isValidCode($code)) {
            return 'contains characters not allowed in a code';
        }
        return null;
    }

    /**
     * 管理者表示名 (user_name) のバリデーション。
     *
     * 管理画面と同等: 必須 / 255 文字以下。
     */
    public static function validateAdminUserName(string $name): ?string
    {
        if ($name === '') {
            return 'is required';
        }
        if (strlen($name) > 255) {
            return 'must be 255 bytes or fewer';
        }
        return null;
    }

    /**
     * 管理者メールアドレス (user_mail) のバリデーション。
     *
     * 管理画面と同等: 必須 / 255 文字以下 / メールアドレス形式。
     * 形式チェックには `filter_var(FILTER_VALIDATE_EMAIL)` を使う (CLI の依存を最小化)。
     */
    public static function validateAdminUserMail(string $mail): ?string
    {
        if ($mail === '') {
            return 'is required';
        }
        if (strlen($mail) > 255) {
            return 'must be 255 bytes or fewer';
        }
        if (filter_var($mail, FILTER_VALIDATE_EMAIL) === false) {
            return 'is not a valid email address';
        }
        return null;
    }

    /**
     * 最初の blog の表示名 (blog_name) のバリデーション。
     *
     * 管理画面の `ACMS_POST_Blog_Insert` と同等: 必須 / 255 文字以下。
     * ただし Installer は未指定 (null/空文字) のとき binName にフォールバックする仕様のため、
     * ここでの「必須」は適用しない (null/空文字はフォールバックの余地として妥当とする)。
     * 値が指定された場合のみ文字数を検査する。
     *
     * 文字数のカウントは {@see self::countCharacters()} を使う。バイト数 (strlen) ではなく
     * 文字数で数えるのは、日本語のブログ名（3 バイト/文字が多い UTF-8）を不当に短く
     * 見積もらないため（`ACMS_Validator::maxlength` と同じ数え方に揃える）。
     *
     * 戻り値: 妥当なら `null`、不正ならエラーメッセージ (1 行)。
     */
    public static function validateBlogName(?string $name): ?string
    {
        if ($name === null || $name === '') {
            return null;
        }
        if (self::countCharacters($name) > 255) {
            return 'must be 255 characters or fewer';
        }
        return null;
    }

    /**
     * 最初の blog の code (blog_code) のバリデーション。
     *
     * 管理画面の `ACMS_POST_Blog_Insert` と同等: 255 文字以下 / 予約語と非衝突
     * (`isReserved()`) / URL として安全な文字列 (`isValidCode()`)。DB 上の重複チェック
     * (`Blog::isCodeExists()`) は稼働中サイトへの追加ブログを前提にした業務ルールであり、
     * Installer は事前に空 DB であることを保証済み（{@see \Acms\Services\Installer\Installer::assertNoExistingBlog()}）
     * のためここには含めない。
     * 未指定 (null/空文字) はドメイン直下運用として妥当なため対象外とする
     * (ルート blog は Installer 側で常に blog_code を空文字に固定する)。
     */
    public static function validateBlogCode(?string $code): ?string
    {
        if ($code === null || $code === '') {
            return null;
        }
        if (self::countCharacters($code) > 255) {
            return 'must be 255 characters or fewer';
        }
        if (isReserved($code)) {
            return 'conflicts with a reserved routing segment';
        }
        if (!isValidCode($code, allowSlash: true)) {
            return 'contains characters not allowed in a code';
        }
        return null;
    }

    /**
     * 最初の blog の domain (blog_domain) のバリデーション。
     *
     * 管理画面の `ACMS_POST_Blog_Insert` と同等の形式チェック (ドメイン形式 / IPv4 / localhost)
     * のみを行う。`Blog::isDomain()` が併せて行うライセンス判定・既存ドメインとの重複チェックは、
     * 稼働中サイトへの追加ブログを前提にした業務ルールであり、空 DB への初回インストールには
     * 適用できないためここには含めない。
     * 未指定 (null/空文字) は config.server.php の DOMAIN 定数へフォールバックする仕様
     * ({@see self::effectiveBlogDomain()}) のため対象外とする。
     */
    public static function validateBlogDomain(?string $domain): ?string
    {
        if ($domain === null || $domain === '') {
            return null;
        }
        if (self::countCharacters($domain) > 128) {
            return 'must be 128 characters or fewer';
        }
        if (preg_match(self::DOMAIN_FORMAT_PATTERN, $domain) !== 1) {
            return 'is not a valid domain, IPv4 address, or "localhost"';
        }
        return null;
    }

    /**
     * `ACMS_Validator::maxlength` と同じ数え方で文字数を数える。
     * CRLF を 1 文字扱いに正規化してから `mb_strlen` (UTF-8) で数える。
     */
    private static function countCharacters(string $value): int
    {
        return mb_strlen(str_replace("\r\n", ' ', $value), 'UTF-8');
    }

    /**
     * DB 設定のいずれかが指定されているか (= config.server.php を書き換える必要があるか)
     */
    public function hasAnyDbOverride(): bool
    {
        return $this->dbHost !== null
            || $this->dbName !== null
            || $this->dbUser !== null
            || $this->dbPassword !== null
            || $this->dbPrefix !== null
            || $this->dbPort !== null
            || $this->dbCharset !== null;
    }

    /**
     * config.server.php に書き込む define() key => value の配列を作る。
     * 指定されていない値は含まない。
     *
     * @return array<string, string|int>
     */
    public function toServerConfigValues(): array
    {
        $values = [];
        if ($this->dbHost !== null) {
            $values['DB_HOST'] = $this->dbHost;
        }
        if ($this->dbName !== null) {
            $values['DB_NAME'] = $this->dbName;
        }
        if ($this->dbUser !== null) {
            $values['DB_USER'] = $this->dbUser;
        }
        if ($this->dbPassword !== null) {
            $values['DB_PASS'] = $this->dbPassword;
        }
        if ($this->dbPrefix !== null) {
            $values['DB_PREFIX'] = $this->dbPrefix;
        }
        if ($this->dbPort !== null) {
            $values['DB_PORT'] = $this->dbPort;
        }
        if ($this->dbCharset !== null) {
            $values['DB_CHARSET'] = $this->dbCharset;
        }
        return $values;
    }
}
