<?php

use Acms\Services\Database\Replication;
use Acms\Services\Database\RestoreStatus;
use Acms\Services\Facades\Cache;
use Acms\Services\Facades\LocalStorage;
use Acms\Services\Facades\PrivateStorage;
use Symfony\Component\Finder\Finder;

class ACMS_POST_Backup_Import extends ACMS_POST_Backup_Base
{
    /**
     * @var \Acms\Services\Database\Replication
     */
    protected $replication;

    /**
     * @var \Acms\Services\Database\RestoreStatus
     */
    protected $restoreStatus;

    /**
     * @var string
     */
    protected $versionCheck;

    /**
     * @var string
     */
    protected $backupTempDir;

    /**
     * @var array
     */
    protected $errorMsg = [];

    /**
     * @inheritDoc
     */
    public function post()
    {
        try {
            $this->authCheck('backup_import');

            AcmsLogger::info('データベースのリストアを開始しました');

            ignore_user_abort(true);
            set_time_limit(0);

            $this->backupTempDir = MEDIA_STORAGE_DIR . 'backup_tmp/';
            $this->replication = App::make('db.replication');
            $this->restoreStatus = new RestoreStatus();
            $this->versionCheck = $this->Post->get('version_check');

            $fileName = $this->Post->get('sqlfile', false);
            $path = Storage::validateDirectoryTraversal($this->backupDatabaseDir, $fileName);
            if (empty($fileName) || !Storage::exists($path)) {
                throw new \RuntimeException('無効なファイルです。DBエクスポートファイルを選択して下さい。');
            }
            $this->decompress($path);
            $hashFilePath = $this->backupTempDir . 'md5_hash.txt';
            $hash = LocalStorage::get($hashFilePath, dirname($hashFilePath));

            if (!$this->fileHashTest($this->backupTempDir . 'sql_query.sql', $hash)) {
                throw new Exception('エクスポートファイルが違うか壊れています。');
            }
            if (!($sql_fp = fopen($this->backupTempDir . 'sql_query.sql', 'r'))) {
                throw new Exception('ファイルを読み込めませんでした。権限を確認して下さい。');
            }

            sleep(3);
            // 先頭行（バージョン情報）を検証する。バージョン不一致やファイル不正は
            // ここで例外となり、フォーム画面にエラーを表示する（フラグは立てない）。
            $data = fgets($sql_fp);
            if ($data === false) {
                fclose($sql_fp);
                throw new Exception('SQLファイルの読み込みに失敗しました。');
            }
            $this->readLineSql(trim($data));
            // 権限確認と、退避／ステージングテーブルの残骸を掃除する。
            $this->validation();

            // 前提検証OK。復元中フラグを立て、進捗ページへリダイレクトしてから
            // バックグラウンドでステージング投入→アトミック入れ替えを実行する。
            // 操作者だけに進捗を見せるため、DB 非依存の Cookie トークンを発行する。
            $restoreToken = bin2hex(random_bytes(16));
            acmsSetCookie('acms_restore_token', $restoreToken, REQUEST_TIME + 3600);
            $this->restoreStatus->begin(null, $restoreToken);
            Common::backgroundRedirect(acmsLink(['bid' => RBID]), 303);
            $this->run($sql_fp);
            die();
        } catch (Exception $e) {
            $this->addError($e->getMessage());
            AcmsLogger::warning('データベースのインポートでエラーが発生しました。', Common::exceptionArray($e));

            return $this->Post;
        }
    }

    /**
     * @param $sql_fp
     * @throws Exception
     */
    protected function run($sql_fp)
    {
        sleep(5);

        try {
            // ステージング投入〜入れ替えの区間は SQL 失敗を確実に検知するため例外スローを有効化する。
            // debug 環境では例外、本番（debug=false）では各クエリの戻り値検査で捕捉する二段構え。
            DB::setThrowException(true);
            // SQL をステージングテーブル（restore_acms_*）へ投入する。
            // この時点では本番テーブルは一切変更しないため、失敗しても元データは無傷。
            while ($sql = fgets($sql_fp)) {
                $this->readLineSql($sql);
            }
            if (is_resource($sql_fp)) {
                fclose($sql_fp);
            }
            // 入れ替え前にステージングの blog テーブルへドメインを反映する。
            $this->replication->rewriteDomain(DOMAIN, Replication::STAGING_PREFIX . DB_PREFIX . 'blog');
            // コミットポイント: ステージングを本番へアトミックに入れ替える。
            $this->replication->swapStagingTables();
        } catch (\Throwable $e) {
            if (is_resource($sql_fp)) {
                fclose($sql_fp);
            }
            $this->restoreStatus->fail('データベースのリストアに失敗しました。元のデータは保持されています。時間をおいて再度お試しください。');
            AcmsLogger::error('データベースのリストアに失敗しました。', Common::exceptionArray($e));
            try {
                $this->replication->dropStagingTables();
            } catch (\Throwable $cleanup) {
                AcmsLogger::notice($cleanup->getMessage());
            }
            return;
        } finally {
            // 後処理は復元成功後に行うため、例外スローの有効化は投入区間で閉じる。
            DB::setThrowException(false);
        }

        // ここから先は入れ替え済み（復元は成功）。後処理が失敗しても復元自体は完了として扱う。
        try {
            if ($this->Post->get('drop_table') === 'on') {
                $this->replication->dropCashTable();
            }
            LocalStorage::removeDirectory($this->backupTempDir);
            Cache::flush('template');
            Cache::flush('config');
            Cache::flush('field');
            Cache::flush('temp');
        } catch (\Throwable $e) {
            AcmsLogger::notice('リストア後のクリーンアップで問題が発生しました。' . $e->getMessage());
        }

        $this->restoreStatus->finish();

        $field = new Field();
        $field->set('backupFileName', $this->Post->get('sqlfile'));
        $this->notify($field);
        AcmsLogger::info('データベースのリストアが完了しました');
    }

    /**
     * @throws Exception
     */
    protected function validation()
    {
        try {
            $this->replication->authorityValidation();
            // 前回の異常終了で残った退避テーブル・ステージングテーブルを掃除する。
            $this->replication->dropCashTable();
            $this->replication->dropStagingTables();
        } catch (Exception $e) {
            throw new Exception($e->getMessage());
        }
    }

    /**
     * @param $file_path
     * @param $hash
     * @return bool
     */
    protected function fileHashTest($file_path, $hash)
    {
        $file_hash = md5_file($file_path);
        if ($file_hash === false) {
            return false;
        }
        $file_hash = mb_convert_encoding($file_hash, "UTF-8");
        if ($file_hash === $hash) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * @param string $line
     * @throws Exception
     */
    protected function readLineSql($line)
    {
        // 本番テーブルではなくステージングテーブル（restore_acms_*）へ投入する。
        // 投入完了後に swapStagingTables() でアトミックに本番へ入れ替える。
        $line = str_replace('DB_PREFIX_STR_', Replication::STAGING_PREFIX . DB_PREFIX, $line);
        $line = mb_convert_encoding($line, DB_CHARSET, "UTF-8");
        if ($line === false) {
            return;
        }
        $line = trim($line); // エクスポートデータによっては、末尾にスペースが入っている場合があるので、trimする
        if (substr($line, 0, 2) === '--') {
            if ($this->versionCheck === 'on' and preg_match('/^--Version_.*/', $line)) {
                $version = trim(substr($line, 2));
                $srv_ver = 'Version_' . VERSION;
                if ($version !== $srv_ver) {
                    throw new Exception("バージョンが違います。$version にアップグレードまたはダウングレードしてからインポートして下さい。現在のバージョン $srv_ver");
                }
            }
        } else {
            $pattern = '/^(CREATE TABLE|INSERT INTO).*;$/is';
            if (preg_match($pattern, $line)) {
                $line = rtrim($line, ';');
                // exec モードは失敗時に例外ではなく false を返す（本番の debug=false 環境）。
                // 戻り値を検査して失敗を確実に検知し、run() の catch でステージングを破棄させる。
                if (DB::query(['sql' => $line, 'params' => []], 'exec') === false) {
                    throw new Exception('リストア用SQLの実行に失敗しました。 ' . implode(' ', DB::errorInfo()));
                }
            }
        }
    }

    /**
     * decompress
     *
     * @param string $path
     */
    protected function decompress(string $path)
    {
        if (!PrivateStorage::exists($path)) {
            throw new \RuntimeException('無効なファイルです。DBエクスポートファイルを選択して下さい。');
        }
        LocalStorage::removeDirectory($this->backupTempDir);
        if (!Common::isLocalPrivateStorage()) {
            LocalStorage::makeDirectory($this->backupDatabaseDir);
            $fileContent = PrivateStorage::get($path);
            if ($fileContent === false) {
                throw new \RuntimeException('ファイルの読み込みに失敗しました: ' . $path);
            }
            LocalStorage::put($path, $fileContent);
        }
        LocalStorage::unzip($path, MEDIA_STORAGE_DIR);

        if (!LocalStorage::exists($this->backupTempDir . 'sql_query.sql')) {
            $finder = new Finder();
            $iterator = $finder
                ->in($this->backupTempDir)
                ->depth('< 2')
                ->name('/(md5_hash\.txt|sql_query\.sql)$/');
            foreach ($iterator as $file) {
                $filename = basename(str_replace('\\', '/', $file->getFilename()));
                LocalStorage::move($this->backupTempDir . $file->getRelativePathname(), $this->backupTempDir . $filename);
            }
        }
    }

    /**
     * @param $field
     * @throws Exception
     */
    protected function notify($field)
    {
        if (
            1
            and $subjectTpl = findTemplate('mail/restore/subject.txt')
            and $bodyTpl = findTemplate('mail/restore/body.txt')
        ) {
            $subject = Common::getMailTxt($subjectTpl, $field);
            $body = Common::getMailTxt($bodyTpl, $field);
            try {
                $mailer = Mailer::init();
                $mailer = $mailer->setFrom(ACMS_RAM::userMail(SUID))
                    ->setTo(ACMS_RAM::userMail(SUID))
                    ->setSubject($subject)
                    ->setBody($body);

                if ($bodyHtmlTpl = findTemplate('mail/restore/body.html')) {
                    $bodyHtml = Common::getMailTxt($bodyHtmlTpl, $field);
                    $mailer = $mailer->setHtml($bodyHtml);
                }
                $mailer->send();
            } catch (Exception $e) {
                AcmsLogger::warning('データベースのインポート完了通知メールの送信に失敗しました', Common::exceptionArray($e));
            }
        }
    }
}
