<?php

declare(strict_types=1);

/** Isolated Store ExportMealOrder test driver; each invocation consumes exactly one synthetic job. */
final class ExportHarness
{
    public $store;
    public $ai;
    public $dialect;
    public $root;
    private $base;
    private $stub;
    private $admin;
    private $adminId = 0;
    private $token;
    private $downloadRoot;
    private $dateDir;
    private $downloadRootExisted;

    public function __construct(string $markerPrefix)
    {
        $this->dialect = (string)getenv('POC_DIALECT');
        $this->root = (string)getenv('POC_APP_ROOT');
        $this->base = rtrim((string)getenv('POC_BASE_URL'), '/');
        $this->stub = (string)getenv('POC_OSS_STUB');
        if (!in_array($this->dialect, ['mysql', 'dm'], true)
            || strpos($this->root, '/tmp/zhct-') !== 0 || $this->base === ''
            || !is_file($this->stub)
            || !is_file($this->root . '/application/base/Security.php')) {
            throw new RuntimeException('Export POC configuration invalid');
        }
        $boundaryContext = stream_context_create(['http' => ['timeout' => 5]]);
        $boundaryBody = @file_get_contents($this->base . '/__poc_boundary', false, $boundaryContext);
        $boundary = is_string($boundaryBody) ? json_decode($boundaryBody, true) : null;
        if (!is_array($boundary) || empty($boundary['external_stubs'])
            || empty($boundary['redis_isolated'])) {
            throw new RuntimeException('Export POC HTTP/Redis isolation absent');
        }
        $this->downloadRoot = $this->root . '/public/static/download';
        $this->dateDir = $this->downloadRoot . '/' . date('Ymd');
        $this->downloadRootExisted = is_dir($this->downloadRoot);
        if (is_link($this->downloadRoot) || is_link($this->dateDir)
            || is_dir($this->dateDir)
            || (file_exists($this->downloadRoot)
                && realpath($this->downloadRoot) !== $this->downloadRoot)) {
            throw new RuntimeException('Export output directory is not exclusive to this test');
        }
        $this->store = $this->connect(false);
        $this->ai = $this->connect(true);
        if ($this->pendingJobs() !== 0) {
            throw new RuntimeException('Other pending export jobs exist');
        }
        $this->admin = $markerPrefix . strtoupper(bin2hex(random_bytes(6)));
        require_once $this->root . '/application/base/Security.php';
        $this->token = \app\base\Security::encrypt(
            implode("\t", ['0', $this->admin, $this->admin, '0', '', '', $this->admin]));
    }

    private function connect(bool $ai): PDO
    {
        $prefix = $ai ? 'POC_AI_' : 'POC_';
        $names = ['HOST', 'PORT', 'SCHEMA', 'USER', 'PASSWORD'];
        $values = [];
        foreach ($names as $name) $values[$name] = (string)getenv($prefix . $name);
        $pid = (string)getenv('POC_RUNTIME_PID');
        if ($this->dialect === 'dm' && $values['HOST'] === '' && ctype_digit($pid)) {
            foreach (explode("\0", (string)file_get_contents('/proc/' . $pid . '/environ')) as $entry) {
                $parts = explode('=', $entry, 2);
                if (count($parts) === 2 && strpos($parts[0], $prefix) === 0) {
                    $name = substr($parts[0], strlen($prefix));
                    if (array_key_exists($name, $values)) $values[$name] = $parts[1];
                }
            }
        }
        if ($this->dialect === 'mysql' && $values['HOST'] === '') {
            $local = $this->root . '/application/config/local/database.php';
            // require inherits caller locals; the A baseline config assigns $ai internally.
            // Load it in a separate scope so it cannot overwrite this method's bool $ai.
            $configFile = is_file($local) ? $local : $this->root . '/application/database.php';
            $config = (static function (string $file): array {
                return require $file;
            })($configFile);
            $selected = $ai ? $config['ai_zhct'] : $config;
            $values = [
                'HOST' => (string)($selected['hostname'] ?? $config['hostname']),
                'PORT' => (string)($selected['hostport'] ?? $config['hostport'] ?? 3306),
                'SCHEMA' => (string)$selected['database'],
                'USER' => (string)($selected['username'] ?? $config['username']),
                'PASSWORD' => (string)($selected['password'] ?? $config['password']),
            ];
        }
        if ($values['HOST'] === '' || $values['SCHEMA'] === '' || $values['USER'] === '') {
            throw new RuntimeException('Export POC DB settings missing');
        }
        $dsn = $this->dialect === 'dm'
            ? "dm:host={$values['HOST']};port={$values['PORT']};schema={$values['SCHEMA']}"
            : "mysql:host={$values['HOST']};port={$values['PORT']};dbname={$values['SCHEMA']};charset=utf8mb4";
        $pdo = new PDO($dsn, $values['USER'], $values['PASSWORD'],
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
        if (getenv('POC_DIAG_CONNECTION') === '1' && $this->dialect === 'mysql') {
            fwrite(STDERR, 'connection role=' . ($ai ? 'ai' : 'store')
                . ' configured=' . $values['SCHEMA']
                . ' actual=' . $pdo->query('SELECT DATABASE()')->fetchColumn() . PHP_EOL);
        }
        return $pdo;
    }

    public function s(string $name): string
    {
        return $this->dialect === 'dm' ? 'YDY_' . strtoupper($name) : 'ydy_' . $name;
    }

    public function a(string $name): string
    {
        return $this->dialect === 'dm' ? 'YOSHOP_' . strtoupper($name) : 'yoshop_' . $name;
    }

    public static function dbString($value): string
    {
        return is_resource($value) ? (string)stream_get_contents($value) : (string)$value;
    }

    public static function one(PDO $db, string $sql, array $params = []): array
    {
        $query = $db->prepare($sql);
        $query->execute($params);
        $row = array_change_key_case($query->fetch(PDO::FETCH_ASSOC) ?: [], CASE_LOWER);
        foreach ($row as $key => $value) {
            if (is_resource($value)) $row[$key] = self::dbString($value);
        }
        return $row;
    }

    public function pendingJobs(): int
    {
        try {
            return (int)$this->store->query('SELECT COUNT(*) FROM ' . $this->s('export_log')
                . ' WHERE STATUS=0')->fetchColumn();
        } catch (PDOException $e) {
            $schema = $this->dialect === 'mysql'
                ? (string)$this->store->query('SELECT DATABASE()')->fetchColumn() : 'dm';
            throw new RuntimeException('Export pending-job read failed in schema=' . $schema,
                0, $e);
        }
    }

    private function post(string $route, array $form, bool $authenticated): array
    {
        $headers = "Content-Type: application/x-www-form-urlencoded\r\n";
        if ($authenticated) $headers .= 'token: ' . $this->token . "\r\n";
        $context = stream_context_create(['http' => [
            'method' => 'POST', 'header' => $headers,
            'content' => http_build_query($form, '', '&', PHP_QUERY_RFC3986),
            'timeout' => 20, 'ignore_errors' => true,
        ]]);
        $body = file_get_contents($this->base . $route, false, $context);
        $headersOut = $http_response_header ?? [];
        if (strpos((string)($headersOut[0] ?? ''), ' 200 ') === false) {
            throw new RuntimeException('Export HTTP status was not 200');
        }
        $json = is_string($body) ? json_decode($body, true) : null;
        if (!is_array($json)) throw new RuntimeException('Export HTTP response was not JSON');
        return $json;
    }

    private static function shape($value): string
    {
        if (!is_array($value)) return gettype($value);
        $keys = array_keys($value);
        if ($keys === range(0, count($keys) - 1)) {
            return '[' . implode(',', array_map(static function ($item): string {
                return self::shape($item);
            }, $value)) . ']';
        }
        sort($keys, SORT_STRING);
        $parts = [];
        foreach ($keys as $key) $parts[] = $key . ':' . self::shape($value[$key]);
        return '{' . implode(',', $parts) . '}';
    }

    private function worker(): int
    {
        $command = escapeshellarg(PHP_BINARY)
            . ' -c ' . escapeshellarg((string)php_ini_loaded_file());
        if ($this->dialect === 'dm') {
            $dir = '/home/dmdba/dmdbms/drivers/php_pdo/';
            $command .= ' -d extension=' . escapeshellarg($dir . 'libphp73_dm.so')
                . ' -d extension=' . escapeshellarg($dir . 'php73_pdo_dm.so');
        }
        $command .= ' -d auto_prepend_file=' . escapeshellarg($this->stub)
            . ' ' . escapeshellarg($this->root . '/think') . ' ExportMealOrder';
        $proc = proc_open($command,
            [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
            $pipes, $this->root);
        if (!is_resource($proc)) throw new RuntimeException('ExportMealOrder CLI did not start');
        $stdout = stream_get_contents($pipes[1]);
        $stderr = stream_get_contents($pipes[2]);
        fclose($pipes[1]);
        fclose($pipes[2]);
        $exit = proc_close($proc);
        if ($exit !== 0) {
            throw new RuntimeException('ExportMealOrder CLI failed, exit=' . $exit
                . ', stdout_bytes=' . strlen((string)$stdout)
                . ', stderr_bytes=' . strlen((string)$stderr));
        }
        return $exit;
    }

    private static function cellMap($xmlRow, string $namespace): array
    {
        $xmlRow->registerXPathNamespace('x', $namespace);
        $result = [];
        foreach ($xmlRow->xpath('./x:c') as $cell) {
            $cell->registerXPathNamespace('x', $namespace);
            $column = preg_replace('/[0-9]+/', '', (string)$cell['r']);
            $inline = $cell->xpath('./x:is/x:t');
            $numeric = $cell->xpath('./x:v');
            $result[$column] = isset($inline[0]) ? (string)$inline[0]
                : (isset($numeric[0]) ? (string)$numeric[0] : '');
        }
        return $result;
    }

    public function run(string $route, string $logsRoute, int $type, array $form,
        callable $assertConditions, callable $assertSheet): array
    {
        $jobId = 0;
        $file = '';
        $results = [];
        try {
            $insert = $this->store->prepare('INSERT INTO ' . $this->s('user')
                . ' (UUID,ACCOUNT,PWD,SALT,NAME,TYPE,CREATE_TIME,CREATE_BY,IS_DELETE)'
                . ' VALUES (?,?,?,?,?,?,?,?,?)');
            $insert->execute([$this->admin, $this->admin, 'DISABLED',
                strtoupper(bin2hex(random_bytes(8))), $this->admin, 0,
                date('Y-m-d H:i:s'), 'CODEX', 0]);
            $row = self::one($this->store, 'SELECT ID FROM ' . $this->s('user')
                . ' WHERE UUID=? AND ACCOUNT=?', [$this->admin, $this->admin]);
            $this->adminId = (int)($row['id'] ?? 0);
            if ($this->adminId <= 0) throw new RuntimeException('Synthetic export operator insert failed');

            $anonymous = $this->post($route, $form, false);
            $jobsForAdmin = $this->store->prepare('SELECT ID FROM ' . $this->s('export_log')
                . ' WHERE CREATE_BY_UUID=? AND TYPE=? ORDER BY ID DESC');
            $jobsForAdmin->execute([$this->admin, $type]);
            $results['anonymous_rejected'] = [
                'status' => (int)($anonymous['code'] ?? -1) === 100
                    && $jobsForAdmin->fetchAll(PDO::FETCH_ASSOC) === []
                    ? 'tested-pass' : 'tested-fail',
                'message' => $anonymous['msg'] ?? $anonymous['message'] ?? null,
                'shape_sha256' => hash('sha256', self::shape($anonymous)),
            ];

            $submitted = $this->post($route, $form, true);
            $jobsForAdmin->execute([$this->admin, $type]);
            $jobs = $jobsForAdmin->fetchAll(PDO::FETCH_ASSOC);
            if ((int)($submitted['code'] ?? -1) !== 0 || count($jobs) !== 1) {
                throw new RuntimeException('Formal export did not create one scoped task');
            }
            $jobId = (int)array_change_key_case($jobs[0], CASE_LOWER)['id'];
            $job = self::one($this->store, 'SELECT ID,TYPE,STATUS,FILE_NAME,FILE_PATH,'
                . ' QUERY_CONDITIONS FROM ' . $this->s('export_log') . ' WHERE ID=?', [$jobId]);
            $conditions = json_decode(self::dbString($job['query_conditions'] ?? ''), true);
            $jobChecks = [
                'type' => (int)($job['type'] ?? -1) === $type,
                'status' => (int)($job['status'] ?? -1) === 0,
                'sole_pending' => $this->pendingJobs() === 1,
                'json_conditions' => is_array($conditions),
                'scoped_conditions' => is_array($conditions) && $assertConditions($conditions),
            ];
            if (in_array(false, $jobChecks, true)) {
                throw new RuntimeException('Export job conditions or sole-pending gate failed: '
                    . json_encode($jobChecks) . ' scoped_values='
                    . json_encode(array_intersect_key((array)$conditions,
                        array_flip(['name', 'account_type', 'direction', 'start_date', 'end_date']))));
            }
            $results['http_enqueue'] = ['status' => 'tested-pass', 'job_type' => $type,
                'job_count' => 1, 'message' => $submitted['message'] ?? null,
                'shape_sha256' => hash('sha256', self::shape($submitted))];

            $this->worker();
            $job = self::one($this->store, 'SELECT ID,STATUS,FILE_NAME,FILE_PATH FROM '
                . $this->s('export_log') . ' WHERE ID=?', [$jobId]);
            if ((int)($job['status'] ?? -1) !== 1
                || self::dbString($job['file_name'] ?? '') === ''
                || self::dbString($job['file_path'] ?? '') === '') {
                $lastLine = trim((string)substr((string)$stdout, -350));
                $lastLine = preg_replace('/(password|pwd|token|key)\s*[:=]\s*\S+/i', '$1=[redacted]', $lastLine);
                throw new RuntimeException('Export worker did not complete this task; status='
                    . ($job['status'] ?? 'missing') . ', output_tail=' . $lastLine);
            }
            $results['worker_complete'] = ['status' => 'tested-pass',
                'job_status' => 1, 'pending_jobs' => $this->pendingJobs()];

            $name = self::dbString($job['file_name']);
            if (preg_match('#^[^/]+_' . $jobId . '\.xlsx$#uD', $name)) {
                $file = $this->dateDir . '/' . $name;
            }
            $path = parse_url(self::dbString($job['file_path']), PHP_URL_PATH);
            if (!is_string($path)
                || preg_match('#^/static/download/[0-9]{8}/[^/]+_' . $jobId . '\.xlsx$#uD', $path) !== 1
                || substr($path, strrpos($path, '/') + 1) !== $name || $file === '') {
                throw new RuntimeException('Export file URL is not this private job');
            }
            $file = $this->root . '/public' . $path;
            if (realpath($file) !== $file || strpos($file, $this->dateDir . '/') !== 0) {
                throw new RuntimeException('Export XLSX escaped private runtime');
            }
            $zip = new ZipArchive();
            if ($zip->open($file) !== true) throw new RuntimeException('Export XLSX ZIP invalid');
            $sheet = $zip->getFromName('xl/worksheets/sheet1.xml');
            $zip->close();
            $xml = is_string($sheet) ? simplexml_load_string($sheet) : false;
            if (!$xml) throw new RuntimeException('Export worksheet XML invalid');
            $namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
            $xml->registerXPathNamespace('x', $namespace);
            $xmlRows = $xml->xpath('//x:sheetData/x:row');
            $rows = array_map(static function ($row) use ($namespace): array {
                return self::cellMap($row, $namespace);
            }, $xmlRows ?: []);
            $sheetEvidence = $assertSheet($rows);
            if (!is_array($sheetEvidence) || ($sheetEvidence['status'] ?? '') !== 'tested-pass') {
                throw new RuntimeException('Export workbook source contract failed');
            }
            $results['xlsx_readback'] = $sheetEvidence
                + ['file_bytes' => filesize($file)];

            $logs = $this->post($logsRoute, ['page' => 1, 'page_size' => 5], true);
            $items = (array)($logs['data']['data'] ?? []);
            $hits = array_values(array_filter($items, static function ($item) use ($jobId): bool {
                return is_array($item) && (int)($item['id'] ?? 0) === $jobId;
            }));
            $results['http_logs'] = [
                'status' => (int)($logs['code'] ?? -1) === 0 && count($hits) === 1
                    && (int)($hits[0]['status'] ?? -1) === 1
                    && (string)($hits[0]['url'] ?? '') === self::dbString($job['file_path'])
                    ? 'tested-pass' : 'tested-fail',
                'matching_job_rows' => count($hits),
                'shape_sha256' => hash('sha256', self::shape($logs)),
            ];
        } finally {
            if ($file !== '' && is_file($file)
                && strpos($file, $this->dateDir . '/') === 0 && !is_link($file)) unlink($file);
            if (is_dir($this->dateDir)) rmdir($this->dateDir);
            if (is_dir($this->downloadRoot) && !$this->downloadRootExisted) rmdir($this->downloadRoot);
            $delete = $this->store->prepare('DELETE FROM ' . $this->s('export_log')
                . ' WHERE CREATE_BY_UUID=? AND TYPE=?');
            $delete->execute([$this->admin, $type]);
            if ($this->adminId > 0) {
                $deleteAdmin = $this->store->prepare('DELETE FROM ' . $this->s('user')
                    . ' WHERE ID=? AND UUID=? AND ACCOUNT=?');
                $deleteAdmin->execute([$this->adminId, $this->admin, $this->admin]);
            }
            if (is_dir($this->dateDir)) throw new RuntimeException('Export test XLSX directory not cleaned');
            $remaining = self::one($this->store, 'SELECT COUNT(*) AS N FROM '
                . $this->s('export_log') . ' WHERE CREATE_BY_UUID=? AND TYPE=?',
                [$this->admin, $type]);
            if ((int)($remaining['n'] ?? -1) !== 0) {
                throw new RuntimeException('Export test task rows not cleaned');
            }
        }
        $results['cleanup'] = ['status' => 'tested-pass',
            'job_rows' => 0, 'operator_rows' => 0, 'file_exists' => false];
        return $results;
    }
}
