<?php
declare(strict_types=1);

// Dedicated Store billing JSON export proof. Never invoke CashRefund/export or cron.
$lane = (string)($argv[1] ?? '');
$mode = (string)($argv[2] ?? '');
$marker = (string)getenv('CDX_BEX_MARKER');
if (!in_array($lane, ['B', 'C'], true)
    || !in_array($mode, ['--preflight', '--execute', '--audit'], true)
    || ($mode !== '--preflight' && !preg_match('/^CDXBEX[0-9A-F]{12}$/D', $marker))
    || ($mode === '--execute' && (
        (string)getenv('CDX_BEX_APPROVE') !== 'ROOT_BILLING_EXPORT_BC_V1'
        || !hash_equals(hash_file('sha256', __FILE__), (string)getenv('CDX_BEX_SHA'))
    ))
    || ($mode === '--audit' && !preg_match('/^[a-f0-9]{64}$/D', (string)getenv('CDX_BEX_SOURCE_SHA')))) {
    fwrite(STDERR, "BILLING EXPORT default deny\n");
    exit(78);
}

$root = $lane === 'B' ? '/tmp/zhct-http-store-mysql.dQTTBP'
    : '/tmp/zhct-dm-poc-20260928.fcpiiN/store';
$base = $lane === 'B' ? 'http://127.0.0.1:18081' : 'http://127.0.0.1:18080';
$pid = $lane === 'B' ? 10001 : 112175;
$expectedIdentity = $lane === 'B'
    ? '003eb5aecf1cd4595b609c1f7f20b9ee325b578309fb9fe14fedaf77584fa401'
    : 'bc98485aed26beab348770a82ae38dbf922b04ab88a210cd7282c4caf0156194';
$sourceHashes = [
    '/application/route.php' => '081b0186b7f40f0bdd237cecb3e2d678fc9344c656932c0d3b3052404e4f1510',
    '/application/p/controller/BillingRecord.php' => 'ef2e8f2a835f21ad92d6dfff7b49e71a79a9cbd2d2dfefc204f452ea4a2e1f1d',
    '/application/p/logic/BillingRecord.php' => '42bf383374c80a8774c014048c189f5c0af135ed30ef71715c05b0c97719f8ca',
    '/application/base/AdminController.php' => '7565b36fa4dda2c21cc4fa3b37753dbffb7c3a464bb8c11ea0f17ca00bececa5',
    '/application/base/Security.php' => 'b82e5ed982e6084a2b2942c033d4767428fb6f1012ba628f609477a7563eb4c3',
    '/application/common/service/BaseService.php' => '0d96474aa40a94abbb291a7730507e1980acf8a5d4f5ab0b8320beb538a637e0',
];
$stage = 'boundary';
try {
    if (PHP_VERSION_ID < 70300 || PHP_VERSION_ID >= 70400 || is_link($root)) {
        throw new RuntimeException('runtime/root drift');
    }
    $runtimeReady = static function () use ($pid, $root, $base): bool {
        $cmd = (string)@file_get_contents('/proc/' . $pid . '/cmdline');
        $children = trim((string)@file_get_contents('/proc/' . $pid . '/task/' . $pid . '/children'));
        $boundary = json_decode((string)@file_get_contents($base . '/__poc_boundary'), true);
        return strpos($cmd, $root . '/public') !== false
            && strpos($cmd, (string)parse_url($base, PHP_URL_PORT)) !== false
            && $children === '' && is_array($boundary)
            && ($boundary['external_stubs'] ?? null) === true
            && ($boundary['redis_isolated'] ?? null) === true
            && ($boundary['real_queue'] ?? true) === false;
    };
    if (!$runtimeReady()) throw new RuntimeException('worker boundary');
    foreach ($sourceHashes as $file => $sha) {
        if (!is_file($root . $file) || is_link($root . $file)
            || !hash_equals($sha, hash_file('sha256', $root . $file))) {
            throw new RuntimeException('source drift');
        }
    }

    $stage = 'database';
    $cfg = require $root . '/application/database.php';
    if (!is_array($cfg) || ($cfg['prefix'] ?? '') !== 'ydy_') {
        throw new RuntimeException('database config');
    }
    if ($lane === 'B') {
        $local = require '/workspace/zhct/zhctproject/store/application/config/local/database.php';
        foreach (['hostname', 'database', 'username', 'password'] as $key) {
            if (($cfg[$key] ?? null) !== ($local[$key] ?? null)) {
                throw new RuntimeException('not code-local MySQL');
            }
        }
        if ((int)($cfg['hostport'] ?? 3306) !== (int)($local['hostport'] ?? 3306)) {
            throw new RuntimeException('not code-local MySQL port');
        }
        $dsn = 'mysql:host=' . $cfg['hostname'] . ';port=' . ($cfg['hostport'] ?? 3306)
            . ';dbname=' . $cfg['database'] . ';charset=utf8mb4';
    } else {
        if ((string)($cfg['hostname'] ?? '') !== '127.0.0.1'
            || (int)($cfg['hostport'] ?? 0) !== 5237
            || strtoupper((string)($cfg['database'] ?? '')) !== 'ZHCT_APP'
            || strtoupper((string)($cfg['username'] ?? '')) !== 'ZHCT_APP'
            || (string)($cfg['password'] ?? '') === '') {
            throw new RuntimeException('not approved DM');
        }
        $dsn = 'dm:host=127.0.0.1;port=5237;schema=ZHCT_APP';
    }
    $db = new PDO($dsn, (string)$cfg['username'], (string)$cfg['password'], [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_CASE => PDO::CASE_LOWER,
        PDO::ATTR_TIMEOUT => 5,
    ]);
    $identity = $lane === 'B'
        ? (string)$db->query("SELECT CONCAT(@@server_uuid,'|',@@hostname,'|',@@port,'|',DATABASE())")->fetchColumn()
        : 'ZHCT_APP|5237|' . (string)$db->query("SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA') FROM DUAL")->fetchColumn();
    if (!hash_equals($expectedIdentity, hash('sha256', $identity))) {
        throw new RuntimeException('DB identity');
    }
    $p = $lane === 'B' ? 'ydy_' : 'YDY_';
    $rows = static function (string $sql, array $args = [], int $cap = 500) use ($db): array {
        if (!preg_match('/^SELECT\b/i', $sql)) throw new RuntimeException('not SELECT');
        $stmt = $db->prepare($sql);
        $stmt->execute($args);
        $result = [];
        while (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) {
            if (count($result) >= $cap) throw new RuntimeException('source cap');
            $row = array_change_key_case($row, CASE_LOWER);
            foreach ($row as $key => $value) {
                if (is_resource($value)) $row[$key] = stream_get_contents($value);
            }
            ksort($row);
            $result[] = $row;
        }
        return $result;
    };
    $count = static function (string $sql, array $args = []) use ($rows): int {
        $v = $rows($sql, $args, 1);
        return (int)(reset($v[0]) ?: 0);
    };
    $userTable = $p . 'user';
    $month = '2026-05';
    $billSql = 'SELECT * FROM ' . $p . 'billing_record'
        . ' WHERE STORE_ID=10001 AND IS_DELETE=0 AND BILL_MONTH=? ORDER BY ID DESC';
    $bills = $rows($billSql, [$month], 101);
    if (count($bills) < 1 || count($bills) > 100) {
        throw new RuntimeException('unbounded/empty natural bills');
    }
    $ids = array_map(static function (array $row): int { return (int)$row['id']; }, $bills);
    if (min($ids) <= 0 || count(array_unique($ids)) !== count($ids)) {
        throw new RuntimeException('bill ID integrity');
    }
    $in = implode(',', array_fill(0, count($ids), '?'));
    $detailSql = 'SELECT * FROM ' . $p . 'billing_record_detail'
        . ' WHERE STORE_ID=10001 AND IS_DELETE=0 AND BILL_ID IN (' . $in . ')'
        . ' ORDER BY BILL_ID DESC,MEAL_TYPE ASC,ID ASC';
    $logSql = 'SELECT * FROM ' . $p . 'billing_record_log'
        . ' WHERE STORE_ID=10001 AND BILL_ID IN (' . $in . ') ORDER BY BILL_ID DESC,ID ASC';
    $snapshot = static function () use ($rows, $billSql, $detailSql, $logSql, $month, $ids): array {
        return [
            'bills' => $rows($billSql, [$month], 101),
            'details' => $rows($detailSql, $ids, 501),
            'logs' => $rows($logSql, $ids, 501),
        ];
    };
    $sourceBefore = $snapshot();
    if (count($sourceBefore['bills']) !== count($bills)
        || count($sourceBefore['details']) < 1
        || count($sourceBefore['details']) > 500
        || count($sourceBefore['logs']) > 500) {
        throw new RuntimeException('source size/drift');
    }
    $digest = static function (array $source): array {
        $result = [];
        foreach ($source as $name => $group) {
            $result[$name] = ['count' => count($group), 'sha256' => hash('sha256', json_encode(
                $group, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE))];
        }
        return $result;
    };
    $before = $digest($sourceBefore);
    $sourceSha = hash('sha256', json_encode($before));
    $oldActorCount = $count("SELECT COUNT(*) FROM " . $userTable
        . " WHERE ACCOUNT LIKE 'CDXBEX%'");
    $preflight = [
        'lane' => $lane,
        'status' => 'preflight-pass',
        'source_counts' => array_map(static function (array $a): int { return count($a); }, $sourceBefore),
        'source_sha256' => $sourceSha,
        'old_actor_count' => $oldActorCount,
        'http_sent' => false,
        'business_dml' => false,
    ];
    if ($mode === '--preflight') {
        echo json_encode($preflight), PHP_EOL;
        exit(0);
    }
    if ($mode === '--audit') {
        $n = $count('SELECT COUNT(*) FROM ' . $userTable
            . ' WHERE UUID=? OR ACCOUNT=? OR NAME=?', [$marker, $marker, $marker]);
        $match = hash_equals((string)getenv('CDX_BEX_SOURCE_SHA'), $sourceSha);
        echo json_encode(['lane' => $lane, 'actor_zero' => $n === 0,
            'source_sha_match' => $match, 'source_counts' => $preflight['source_counts']]), PHP_EOL;
        exit($n === 0 && $match ? 0 : 3);
    }
    if ($oldActorCount !== 0) throw new RuntimeException('old billing export actor exists');

    // No DML or HTTP before the explicit execution gate above.
    echo json_encode(['lane' => $lane, 'status' => 'approved-execute',
        'marker' => $marker, 'source_sha256' => $sourceSha]), PHP_EOL;
    $stage = 'actor';
    $actorInserted = false;
    $failure = '';
    $result = ['lane' => $lane, 'marker' => $marker, 'paths' => [],
        'source_sha256' => $sourceSha, 'source_unchanged' => false, 'actor_zero' => false];
    $typeTree = static function ($value) use (&$typeTree) {
        if ($value instanceof stdClass) {
            $fields = [];
            foreach (get_object_vars($value) as $key => $item) $fields[$key] = $typeTree($item);
            ksort($fields);
            return ['object' => $fields];
        }
        if (is_array($value)) {
            return ['array' => array_map($typeTree, $value)];
        }
        return gettype($value);
    };
    $post = static function (string $path, string $token) use ($base, $runtimeReady, $month, $typeTree): array {
        if (!in_array($path, ['/p/BillingRecord/export', '/p/billingrecord/export'], true)
            || !$runtimeReady()) throw new RuntimeException('HTTP boundary');
        $context = stream_context_create(['http' => ['method' => 'POST',
            'header' => "Content-Type: application/x-www-form-urlencoded\r\ntoken: " . $token . "\r\n",
            'content' => http_build_query(['month' => $month], '', '&', PHP_QUERY_RFC3986),
            'timeout' => 20, 'ignore_errors' => true]]);
        $stream = @fopen($base . $path, 'rb', false, $context);
        $body = is_resource($stream) ? stream_get_contents($stream, 2097153) : false;
        $meta = is_resource($stream) ? stream_get_meta_data($stream) : [];
        if (is_resource($stream)) fclose($stream);
        if (!is_string($body) || strlen($body) > 2097152 || !empty($meta['timed_out'])
            || strpos((string)($http_response_header[0] ?? ''), ' 200 ') === false) {
            throw new RuntimeException('HTTP incomplete');
        }
        $json = json_decode($body, true);
        $object = json_decode($body, false);
        if (!is_array($json) || ($json['code'] ?? null) !== 0
            || !is_array($json['data'] ?? null) || !($object instanceof stdClass)) {
            throw new RuntimeException('HTTP business response');
        }
        return ['json' => $json, 'type_tree' => $typeTree($object)];
    };
    try {
        if ($count('SELECT COUNT(*) FROM ' . $userTable
            . ' WHERE UUID=? OR ACCOUNT=? OR NAME=?', [$marker, $marker, $marker]) !== 0) {
            throw new RuntimeException('actor collision');
        }
        $insert = $db->prepare('INSERT INTO ' . $userTable
            . ' (UUID,ACCOUNT,PWD,SALT,NAME,TYPE,CREATE_TIME,CREATE_BY,IS_DELETE)'
            . ' VALUES (?,?,?,?,?,?,?,?,?)');
        $insert->execute([$marker, $marker, 'DISABLED', strtoupper(bin2hex(random_bytes(8))),
            $marker, 0, date('Y-m-d H:i:s'), 'CODEX', 0]);
        if ($insert->rowCount() !== 1) throw new RuntimeException('actor insert');
        $actorInserted = true;
        $actor = $rows('SELECT ID FROM ' . $userTable
            . ' WHERE UUID=? AND ACCOUNT=? AND NAME=? AND TYPE=0 AND CREATE_BY=?',
            [$marker, $marker, $marker, 'CODEX'], 2);
        if (count($actor) !== 1) throw new RuntimeException('actor identity');
        require_once $root . '/application/base/Security.php';
        $token = \app\base\Security::encrypt(implode("\t",
            [(string)$actor[0]['id'], $marker, $marker, '0', '', '', $marker]));
        if (!is_string($token) || $token === '') throw new RuntimeException('token');
        $expected = $sourceBefore['bills'];
        $byBill = [];
        foreach ($sourceBefore['details'] as $detail) {
            $byBill[(int)$detail['bill_id']][(int)$detail['meal_type']] = $detail;
        }
        $priorData = null;
        foreach (['/p/BillingRecord/export', '/p/billingrecord/export'] as $path) {
            $stage = 'HTTP:' . $path;
            $response = $post($path, $token);
            $data = $response['json']['data'];
            if (count($data) !== count($expected)) throw new RuntimeException('export row count');
            foreach ($data as $i => $row) {
                $bill = $expected[$i];
                $billId = (int)$bill['id'];
                if (!is_array($row)
                    || (int)($row['id'] ?? 0) !== $billId
                    || (string)($row['bill_no'] ?? '') !== (string)$bill['bill_no']
                    || (string)($row['bill_month'] ?? '') !== $month
                    || (int)($row['status_value'] ?? -1) !== (int)$bill['status']
                    || number_format((float)($row['amount'] ?? -1), 2, '.', '')
                        !== number_format((float)$bill['amount'], 2, '.', '')) {
                    throw new RuntimeException('export bill source');
                }
                foreach (['breakfast' => 1, 'lunch' => 2, 'dinner' => 3] as $meal => $type) {
                    $detail = $byBill[$billId][$type] ?? null;
                    $n = $detail ? (int)$detail['adjusted_count'] : 0;
                    $amount = $detail ? (float)$detail['adjusted_amount'] : 0.0;
                    if ((int)($row[$meal . '_count'] ?? -1) !== $n
                        || number_format((float)($row[$meal . '_amount'] ?? -1), 2, '.', '')
                            !== number_format($amount, 2, '.', '')) {
                        throw new RuntimeException('export meal source');
                    }
                }
            }
            if ($priorData !== null && $data !== $priorData) {
                throw new RuntimeException('canonical/PC route result');
            }
            $priorData = $data;
            $result['paths'][$path] = ['http' => 200, 'code' => 0,
                'rows' => count($data), 'source_match' => true,
                'type_tree' => $response['type_tree']];
        }
        $stage = 'source-after';
        $result['source_unchanged'] = $digest($snapshot()) === $before;
        if (!$result['source_unchanged']) throw new RuntimeException('source changed');
    } catch (Throwable $error) {
        $failure = $stage . ':' . get_class($error) . ':' . (string)$error->getCode()
            . ':' . substr(hash('sha256', $error->getMessage()), 0, 12);
    } finally {
        if ($actorInserted) {
            $actor = $rows('SELECT ID FROM ' . $userTable
                . ' WHERE UUID=? AND ACCOUNT=? AND NAME=? AND TYPE=0 AND CREATE_BY=?',
                [$marker, $marker, $marker, 'CODEX'], 2);
            if (count($actor) !== 1) throw new RuntimeException('actor cleanup identity');
            $delete = $db->prepare('DELETE FROM ' . $userTable
                . ' WHERE ID=? AND UUID=? AND ACCOUNT=? AND NAME=? AND TYPE=0 AND CREATE_BY=?');
            $delete->execute([(int)$actor[0]['id'], $marker, $marker, $marker, 'CODEX']);
            if ($delete->rowCount() !== 1) throw new RuntimeException('actor cleanup affected');
        }
        $result['actor_zero'] = $count('SELECT COUNT(*) FROM ' . $userTable
            . ' WHERE UUID=? OR ACCOUNT=? OR NAME=?', [$marker, $marker, $marker]) === 0;
        if (!$result['source_unchanged']) {
            try { $result['source_unchanged'] = $digest($snapshot()) === $before; }
            catch (Throwable $sourceError) {
                $failure .= '|source-after:' . get_class($sourceError) . ':'
                    . substr(hash('sha256', $sourceError->getMessage()), 0, 12);
            }
        }
    }
    $result['failure'] = $failure;
    echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), PHP_EOL;
    exit($failure === '' && $result['source_unchanged'] && $result['actor_zero'] ? 0 : 1);
} catch (Throwable $error) {
    echo json_encode(['lane' => $lane, 'status' => 'blocked-before-http',
        'stage' => $stage, 'class' => get_class($error),
        'message_sha12' => substr(hash('sha256', $error->getMessage()), 0, 12)]), PHP_EOL;
    exit(2);
}
