<?php
/**
 * 신규주문 전용 간소화 엔드포인트
 * - action=prepare
 * - action=approve
 */

include_once('./_common.php');

header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');

function svb_req($key, $default = '')
{
    if (isset($_POST[$key])) {
        return trim((string)$_POST[$key]);
    }
    if (isset($_GET[$key])) {
        return trim((string)$_GET[$key]);
    }
    return $default;
}

function svb_out($arr)
{
    echo json_encode($arr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

function svb_success($action, $message, $extra = array())
{
    $base = array(
        'result' => 'success',
        'action' => (string)$action,
        'message' => (string)$message,
        'request_id' => svb_request_id(),
        'api_revision' => defined('SVB_API_REVISION') ? SVB_API_REVISION : '',
    );
    svb_out(array_merge($base, $extra));
}

function svb_fail($action, $code, $message, $extra = array())
{
    $reqAmount = svb_to_int(svb_req('amount', svb_req('approved_amount', '0')), 0);
    $reqProductId = (string)svb_req('product_id', '');
    $reqPlanType = (string)svb_req('plan_type', svb_req('plan', ''));
    $reqQty = svb_to_int(svb_req('qty', '0'), 0);
    $reqUnitHours = 0;
    $detailRowsRawReq = svb_req('detail_rows', '');
    if ($detailRowsRawReq !== '') {
        $rowsReq = svb_parse_detail_rows($detailRowsRawReq);
        if (is_array($rowsReq) && !isset($rowsReq['__error']) && count($rowsReq) > 0 && is_array($rowsReq[0])) {
            $r0 = $rowsReq[0];
            if ($reqProductId === '' && isset($r0['product_id'])) {
                $reqProductId = (string)$r0['product_id'];
            }
            if ($reqQty < 1 && isset($r0['qty'])) {
                $reqQty = (int)$r0['qty'];
            }
            if (isset($r0['unit_hours'])) {
                $reqUnitHours = (int)$r0['unit_hours'];
            }
        }
    }
    $reason = '';
    if (is_array($extra) && isset($extra['fail_reason'])) {
        $reason = trim((string)$extra['fail_reason']);
    }
    if ($reason === '') {
        $reason = strtolower((string)$code);
    }
    $base = array(
        'result' => 'fail',
        'action' => (string)$action,
        'code' => (string)$code,
        'fail_code' => (string)$code,
        'fail_reason' => (string)$reason,
        'message' => (string)$message,
        'request_id' => svb_request_id(),
        'api_revision' => defined('SVB_API_REVISION') ? SVB_API_REVISION : '',
        'expected_amount' => is_array($extra) && isset($extra['expected_amount']) ? (int)$extra['expected_amount'] : 0,
        'received_amount' => is_array($extra) && isset($extra['received_amount']) ? (int)$extra['received_amount'] : (int)$reqAmount,
        'product_id' => is_array($extra) && isset($extra['product_id']) ? (string)$extra['product_id'] : $reqProductId,
        'plan_type' => is_array($extra) && isset($extra['plan_type']) ? (string)$extra['plan_type'] : $reqPlanType,
        'qty' => is_array($extra) && isset($extra['qty']) ? (int)$extra['qty'] : (int)$reqQty,
        'unit_hours' => is_array($extra) && isset($extra['unit_hours']) ? (int)$extra['unit_hours'] : (int)$reqUnitHours,
        'expected_expiry_before' => is_array($extra) && isset($extra['expected_expiry_before']) ? (string)$extra['expected_expiry_before'] : '',
        'expected_expiry_after' => is_array($extra) && isset($extra['expected_expiry_after']) ? (string)$extra['expected_expiry_after'] : '',
    );
    svb_out(array_merge($base, $extra));
}

function svb_lookup_profile_expiry_by_item($mbId, $itId, $forUpdate = false)
{
    global $g5;
    $mbId = trim((string)$mbId);
    $itId = trim((string)$itId);
    if ($mbId === '' || $itId === '' || !isset($g5['g5_shop_item_table'])) {
        return '';
    }
    $safeIt = svb_sql_escape($itId);
    $item = sql_fetch("SELECT it_name FROM {$g5['g5_shop_item_table']} WHERE it_id='{$safeIt}' LIMIT 1", false);
    $itName = isset($item['it_name']) ? trim((string)$item['it_name']) : '';
    if ($itName === '') {
        return '';
    }
    $safeMb = svb_sql_escape($mbId);
    $safeName = svb_sql_escape($itName);
    $sql = "SELECT MAX(vpn_id_expire) AS ex
              FROM vpnid_list
             WHERE (mb_id='{$safeMb}' OR remark='{$safeMb}' OR member_id='{$safeMb}')
               AND it_name='{$safeName}'";
    if ($forUpdate) {
        $sql .= ' FOR UPDATE';
    }
    $row = sql_fetch($sql, false);
    return isset($row['ex']) ? trim((string)$row['ex']) : '';
}

function svb_lock_name_for_product($mbId, $itId)
{
    $seed = 'svb_expiry|' . trim((string)$mbId) . '|' . trim((string)$itId);
    return 'svb_exp_' . substr(hash('sha256', $seed), 0, 24);
}

function svb_acquire_named_lock($name, $timeoutSec)
{
    $name = trim((string)$name);
    $timeoutSec = max(1, (int)$timeoutSec);
    if ($name === '') {
        return false;
    }
    $safeName = svb_sql_escape($name);
    $row = sql_fetch("SELECT GET_LOCK('{$safeName}', {$timeoutSec}) AS l", false);
    return is_array($row) && isset($row['l']) && (string)$row['l'] === '1';
}

function svb_release_named_lock($name)
{
    $name = trim((string)$name);
    if ($name === '') {
        return;
    }
    $safeName = svb_sql_escape($name);
    sql_fetch("SELECT RELEASE_LOCK('{$safeName}') AS r", false);
}

function svb_prepare_debug_snapshot($extra = array())
{
    $rawProductId = svb_req('product_id', '');
    $ids = svb_parse_product_ids($rawProductId, svb_req('product_ids', ''));
    $detailRowsRaw = svb_req('detail_rows', '');
    $detailRows = svb_parse_detail_rows($detailRowsRaw);
    $dump = svb_dump_detail_rows($detailRows);
    $expectedAmount = 0;
    if (isset($dump['rows']) && is_array($dump['rows'])) {
        foreach ($dump['rows'] as $rr) {
            if (is_array($rr) && isset($rr['line_amount'])) {
                $expectedAmount += (int)$rr['line_amount'];
            }
        }
    }

    $debug = array(
        'mb_id' => (string)svb_req('mb_id', svb_req('username', '')),
        'ca_id' => (string)svb_req('ca_id', ''),
        'product_id' => (string)$rawProductId,
        'product_ids' => array_values($ids),
        'plan' => (string)svb_req('plan', svb_req('plan_type', '')),
        'plan_type' => (string)svb_req('plan_type', ''),
        'request_amount' => (int)svb_to_int(svb_req('amount', '0'), 0),
        'expected_amount' => (int)$expectedAmount,
        'detail_rows_len' => strlen((string)$detailRowsRaw),
        'row_count' => isset($dump['row_count']) ? (int)$dump['row_count'] : 0,
        'rows' => isset($dump['rows']) ? $dump['rows'] : array(),
        'parse_error' => isset($dump['parse_error']) ? (string)$dump['parse_error'] : '',
    );
    if (isset($dump['parse_error_line'])) {
        $debug['parse_error_line'] = (string)$dump['parse_error_line'];
    }
    if (isset($dump['invalid_row_index'])) {
        $debug['invalid_row_index'] = (int)$dump['invalid_row_index'];
    }
    if (is_array($extra)) {
        $debug = array_merge($debug, $extra);
    }
    return $debug;
}

function svb_fail_with_prepare_debug($action, $code, $message, $failReason = '', $extraDebug = array())
{
    if ((string)$action === 'prepare') {
        $reason = $failReason !== '' ? (string)$failReason : strtolower((string)$code);
        $dbg = svb_prepare_debug_snapshot(array_merge(
            array('fail_code' => (string)$code, 'fail_reason' => $reason),
            is_array($extraDebug) ? $extraDebug : array()
        ));
        svb_fail('prepare', $code, $message, array('debug' => $dbg));
    }
    svb_fail($action, $code, $message);
}

function svb_sql_escape($v)
{
    return function_exists('sql_real_escape_string')
        ? sql_real_escape_string((string)$v)
        : addslashes((string)$v);
}

function svb_to_int($v, $default = 0)
{
    return is_numeric($v) ? (int)$v : (int)$default;
}

function svb_currency($v)
{
    $v = strtoupper(trim((string)$v));
    if ($v === '') {
        return 'KRW';
    }
    $v = preg_replace('/[^A-Z]/', '', $v);
    return $v === '' ? 'KRW' : $v;
}

function svb_normalize_plan($v)
{
    $v = strtolower(trim((string)$v));
    if ($v === '') {
        return 'hour';
    }
    // [FIX 2026-05-08] 'all' 명시 허용 — 다건 혼합 주문에서 클라이언트가 단일 plan 을
    // 강제하지 않겠다는 의미로 'all' 을 보낸다. detail_rows 의 라인별 unit_hours 가
    // 진실의 원천이며, 'all' 은 'hour' 로 정규화하지 않고 그대로 전달한다.
    if (in_array($v, array('usage', 'hour', 'day', 'month', 'year', 'all'), true)) {
        return $v;
    }
    return 'hour';
}

function svb_generate_order_id()
{
    return 'ORD-' . date('Ymd-His') . '-' . mt_rand(1000, 9999);
}

function svb_generate_token($orderId, $mbId)
{
    return 'APV-' . substr(hash('sha256', $orderId . '|' . $mbId . '|' . microtime(true) . '|' . mt_rand()), 0, 8);
}

function svb_member_by_id_or_email($id)
{
    global $g5;

    $id = trim((string)$id);
    if ($id === '') {
        return array();
    }

    if (filter_var($id, FILTER_VALIDATE_EMAIL)) {
        $safe = svb_sql_escape($id);
        $m = sql_fetch("SELECT * FROM {$g5['member_table']} WHERE mb_email='{$safe}' LIMIT 1", false);
        if (!empty($m['mb_id'])) {
            return $m;
        }
    }

    return get_member($id);
}

function svb_check_login_password($member, $password)
{
    if (empty($member['mb_id']) || $password === '') {
        return false;
    }
    if (!function_exists('login_password_check')) {
        return false;
    }
    return login_password_check($member, $password, $member['mb_password']);
}

function svb_parse_product_ids($productId, $productIds)
{
    $ids = array();

    $single = trim((string)$productId);
    if ($single !== '') {
        $single = preg_replace('/[^A-Za-z0-9_\-]/', '', $single);
        if ($single !== '') {
            $ids[] = $single;
        }
    }

    $csv = trim((string)$productIds);
    if ($csv !== '') {
        foreach (explode(',', $csv) as $p) {
            $p = preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string)$p));
            if ($p !== '') {
                $ids[] = $p;
            }
        }
    }

    $ids = array_values(array_unique($ids));
    if (count($ids) > 100) {
        $ids = array_slice($ids, 0, 100);
    }

    return $ids;
}

function svb_parse_qty_list($selectedQtyCsv, $count)
{
    $count = max(1, (int)$count);
    $qty = array();

    $csv = trim((string)$selectedQtyCsv);
    if ($csv !== '') {
        $parts = explode(',', $csv);
        foreach ($parts as $p) {
            $q = svb_to_int(trim((string)$p), 1);
            if ($q < 1) {
                $q = 1;
            }
            $qty[] = $q;
            if (count($qty) >= $count) {
                break;
            }
        }
    }

    while (count($qty) < $count) {
        $qty[] = 1;
    }

    return $qty;
}

/**
 * 라인별 plan_type 또는 hours CSV 파싱(혼합 주문 지원).
 * - selected_plans_csv : "hour,day,month" 등 (공통 plan 보다 우선)
 * - selected_unit_hours_csv : 명시적 시간 단위 1순위로 사용
 */
function svb_parse_plan_list($csv, $count, $defaultPlan)
{
    $count = max(1, (int)$count);
    $out = array();
    $csv = trim((string)$csv);
    if ($csv !== '') {
        foreach (explode(',', $csv) as $p) {
            $out[] = svb_normalize_plan(trim((string)$p));
            if (count($out) >= $count) {
                break;
            }
        }
    }
    while (count($out) < $count) {
        $out[] = svb_normalize_plan($defaultPlan);
    }
    return $out;
}

function svb_parse_unit_hours_list($csv, $count)
{
    $count = max(1, (int)$count);
    $out = array();
    $csv = trim((string)$csv);
    if ($csv !== '') {
        foreach (explode(',', $csv) as $p) {
            $h = svb_to_int(trim((string)$p), 0);
            $out[] = $h > 0 ? $h : 0;
            if (count($out) >= $count) {
                break;
            }
        }
    }
    while (count($out) < $count) {
        $out[] = 0;
    }
    return $out;
}

function svb_item_unit_hours($item, $reqPlan)
{
    // [FIX 2026-05-07]
    // 과거에는 it_buy_min_qty 가 0보다 크면 그 값을 "시간 단위"로 그대로 사용했다.
    // 그러나 g5_shop 운영 데이터에서 it_buy_min_qty=1 은 "1단위(시/일/월/년) 최소 구매수량"
    // 의 의미로 쓰이고 있어, 결과적으로 day/month/year 요금제 주문이 모두 1시간으로 반영되는
    // 1시간 고정 버그가 발생했다.
    // unit 시간은 반드시 클라이언트가 보낸 plan_type 으로 환산한다.
    // it_buy_min_qty 는 prepare 단계의 수량 검증에만 사용한다.
    $plan = svb_normalize_plan($reqPlan);
    if ($plan === 'day') {
        return 24;
    }
    if ($plan === 'month') {
        return 24 * 30;
    }
    if ($plan === 'year') {
        return 24 * 365;
    }
    return 1;
}

function svb_unit_days_by_plan_and_hours($plan, $unitHours)
{
    $plan = svb_normalize_plan($plan);
    if ($plan === 'day') {
        return 1;
    }
    if ($plan === 'month') {
        return 30;
    }
    if ($plan === 'year') {
        return 365;
    }
    return round(((int)$unitHours) / 24, 6);
}

/**
 * [FIX 2026-05-07] detail_rows 파서
 * 형식: name|hours|price|country|product_id|qty|unit_hours|unit_price[|option_spec]
 *  - 1=name, 2=line_hours, 3=line_price, 4=country, 5=product_id,
 *    6=qty, 7=unit_hours, 8=unit_price, 9=option_spec(optional)
 *  - 행 구분자: \n, \r\n, ';', '||'
 *  - 8/9 컬럼 모두 허용
 *  - 파싱 실패 시 빈 배열 반환(상위에서 fallback 가능)
 */
function svb_parse_detail_rows($detailRowsStr)
{
    $raw = trim((string)$detailRowsStr);
    if ($raw === '') {
        return array();
    }
    $raw = str_replace(array('\\r\\n', '\\n', '\\r'), "\n", $raw);
    // [FIX 2026-05-08] 앱 로그형 문자열 지원:
    //   rows=2 [id=10813,qty=1,unitH=1,unitP=99;id=10812,qty=1,unitH=1,unitP=120]
    if (preg_match('/^\s*rows\s*=\s*\d+\s*\[(.*)\]\s*$/i', $raw, $m)) {
        $raw = (string)$m[1];
    }
    $normalized = str_replace(array("\r\n", "\r", '||'), "\n", $raw);
    $normalized = str_replace(';', "\n", $normalized);
    $lines = preg_split('/\n+/', $normalized);

    $rows = array();
    foreach ($lines as $lineIdx => $line) {
        $line = trim((string)$line);
        if ($line === '') {
            continue;
        }
        $name = '';
        $lineHours = 0;
        $linePrice = 0;
        $country = '';
        $productId = '';
        $qty = 0;
        $unitHours = 0;
        $unitPrice = 0;
        $optionSpec = '';

        $cols = explode('|', $line);
        if (count($cols) >= 8) {
            // 표준 포맷: name|totalHours|totalPrice|country|product_id|qty|unit_hours|unit_price[|option_spec]
            $name = trim((string)$cols[0]);
            $lineHours = (int)$cols[1];
            $linePrice = (int)$cols[2];
            $country = trim((string)$cols[3]);
            $productId = preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string)$cols[4]));
            $qty = (int)$cols[5];
            $unitHours = (int)$cols[6];
            $unitPrice = (int)$cols[7];
            $optionSpec = isset($cols[8]) ? trim((string)$cols[8]) : '';
        } else {
            // [FIX 2026-05-08] 앱 실로그 호환: key=value 형태 지원
            // 예) row1: product_id=10819, qty=1, unit_hours=1, unit_price=248
            $tmp = preg_replace('/^row\d+\s*:\s*/i', '', $line);
            // 예) row1(id=10819,qty=1,unit_hours=1,unit_price=248)
            $tmp = preg_replace('/^row\d+\s*\((.*)\)\s*$/i', '$1', (string)$tmp);
            $parts = preg_split('/\s*,\s*/', (string)$tmp);
            $kv = array();
            foreach ((array)$parts as $part) {
                if (strpos((string)$part, '=') === false) {
                    continue;
                }
                list($k, $v) = array_pad(explode('=', (string)$part, 2), 2, '');
                $k = strtolower(trim((string)$k));
                $v = trim((string)$v);
                if ($k !== '') {
                    $kv[$k] = $v;
                }
            }

            $name = isset($kv['name']) ? (string)$kv['name'] : '';
            $country = isset($kv['country']) ? (string)$kv['country'] : '';
            $productId = isset($kv['product_id']) ? preg_replace('/[^A-Za-z0-9_\-]/', '', (string)$kv['product_id']) : '';
            $qty = isset($kv['qty']) ? (int)$kv['qty'] : 0;
            $unitHours = isset($kv['unit_hours']) ? (int)$kv['unit_hours'] : 0;
            if ($unitHours < 1 && isset($kv['unith'])) {
                $unitHours = (int)$kv['unith'];
            }
            $unitPrice = isset($kv['unit_price']) ? (int)$kv['unit_price'] : 0;
            if ($unitPrice < 1 && isset($kv['unitp'])) {
                $unitPrice = (int)$kv['unitp'];
            }
            $lineHours = isset($kv['totalhours']) ? (int)$kv['totalhours'] : (isset($kv['total_hours']) ? (int)$kv['total_hours'] : 0);
            $linePrice = isset($kv['totalprice']) ? (int)$kv['totalprice'] : (isset($kv['total_price']) ? (int)$kv['total_price'] : 0);
            $optionSpec = isset($kv['option_spec']) ? (string)$kv['option_spec'] : '';

            if ($productId === '' && isset($kv['id'])) {
                $productId = preg_replace('/[^A-Za-z0-9_\-]/', '', (string)$kv['id']);
            }
        }

        // [FIX 2026-05-08] product_id 누락은 명시적 실패(절대 fallback 금지)
        if ($productId === '') {
            return array('__error' => 'ROW_PARSE_FAIL', '__line' => $line, '__invalid_row_index' => (int)$lineIdx);
        }
        if ($qty < 1) {
            $qty = 1;
        }

        // [FIX 2026-05-08]
        // 우선순위: qty/unit_hours/unit_price 우선, 없으면 totalHours/totalPrice fallback.
        // 1시간 고정 오기재 방지를 위해 line_hours/line_amount 는 최종적으로 unit*qty 기반으로 확정한다.
        if ($unitHours < 1) {
            if ($lineHours >= $qty) {
                $unitHours = (int)floor($lineHours / $qty);
            }
        }
        if ($unitHours < 1) {
            return array('__error' => 'PERIOD_INVALID', '__line' => $line, '__invalid_row_index' => (int)$lineIdx);
        }
        if ($unitPrice < 0) {
            $unitPrice = 0;
        }
        if ($unitPrice <= 0 && $linePrice > 0 && $qty > 0) {
            $unitPrice = (int)floor($linePrice / $qty);
        }
        if ($linePrice < 0) {
            $linePrice = 0;
        }

        // qty/unit 기반으로 line을 확정(우선), unit 정보가 없는 경우에만 total fallback.
        $calcLineHours = $unitHours * $qty;
        $calcLinePrice = $unitPrice * $qty;
        if ($calcLineHours > 0) {
            $lineHours = $calcLineHours;
        } elseif ($lineHours <= 0) {
            $lineHours = 0;
        }
        if ($calcLinePrice > 0) {
            $linePrice = $calcLinePrice;
        } elseif ($linePrice <= 0) {
            $linePrice = 0;
        }

        $rows[] = array(
            'product_id' => $productId,
            'product_name' => $name !== '' ? $name : ('VPN-' . $productId),
            'country' => $country,
            'qty' => $qty,
            'unit_hours' => $unitHours,
            'unit_price' => $unitPrice,
            'line_hours' => $lineHours,
            'line_amount' => $linePrice,
            'option_spec' => $optionSpec,
        );
    }

    return $rows;
}

function svb_inspect_items_for_prepare($ids)
{
    global $g5;
    $out = array(
        'found' => array(),
        'not_found' => array(),
        'not_sale' => array(),
    );
    if (!isset($g5['g5_shop_item_table']) || !is_array($ids) || count($ids) < 1) {
        $out['not_found'] = is_array($ids) ? $ids : array();
        return $out;
    }

    $in = array();
    foreach ($ids as $id) {
        $in[] = "'" . svb_sql_escape($id) . "'";
    }
    $sql = "SELECT it_id, it_use, COALESCE(it_soldout,'0') AS it_soldout
              FROM {$g5['g5_shop_item_table']}
             WHERE it_id IN (" . implode(',', $in) . ")";
    $res = sql_query($sql, false);
    $status = array();
    if ($res) {
        while ($row = sql_fetch_array($res)) {
            $itId = isset($row['it_id']) ? trim((string)$row['it_id']) : '';
            if ($itId === '') {
                continue;
            }
            $status[$itId] = array(
                'it_use' => isset($row['it_use']) ? trim((string)$row['it_use']) : '0',
                'it_soldout' => isset($row['it_soldout']) ? trim((string)$row['it_soldout']) : '0',
            );
        }
    }

    foreach ($ids as $id) {
        if (!isset($status[$id])) {
            $out['not_found'][] = (string)$id;
            continue;
        }
        if ($status[$id]['it_use'] !== '1' || $status[$id]['it_soldout'] === '1') {
            $out['not_sale'][] = (string)$id;
            continue;
        }
        $out['found'][] = (string)$id;
    }
    return $out;
}

/**
 * [FIX 2026-05-07] detail_rows 기반 payload 빌더
 * 클라이언트 스냅샷(요금제 변경 후 값)을 신뢰 기준으로 사용한다.
 */
function svb_payload_from_detail_rows($mbId, $detailRows, $itemMaster, $defaultPlan, $currency)
{
    if (count($detailRows) < 1) {
        return array();
    }

    $lines = array();
    $sumAmount = 0;
    $sumHours = 0;
    $sumQty = 0;

    foreach ($detailRows as $r) {
        $pid = (string)$r['product_id'];
        $unitHours = (int)$r['unit_hours'];
        $unitPrice = (int)$r['unit_price'];
        $qty = max(1, (int)$r['qty']);
        $lineAmount = max(0, (int)$r['line_amount']);
        $lineHours = max(0, (int)$r['line_hours']);

        // [FIX 2026-05-08] qty/unit 우선으로 누적시간/금액 확정
        $calcHours = $unitHours * $qty;
        $calcAmount = $unitPrice * $qty;
        if ($calcHours > 0) {
            $lineHours = $calcHours;
        }
        if ($calcAmount > 0) {
            $lineAmount = $calcAmount;
        }
        if ($lineHours < 1) {
            return array();
        }

        // 시간/가격 단위에 맞춘 plan 추정
        $linePlan = svb_normalize_plan($defaultPlan);
        if ($unitHours >= 24 * 365) {
            $linePlan = 'year';
        } elseif ($unitHours >= 24 * 30) {
            $linePlan = 'month';
        } elseif ($unitHours >= 24) {
            $linePlan = 'day';
        } else {
            $linePlan = 'hour';
        }

        $itemName = isset($r['product_name']) ? (string)$r['product_name'] : ('VPN-' . $pid);
        if (isset($itemMaster[$pid]['it_name']) && $itemMaster[$pid]['it_name'] !== '') {
            $itemName = (string)$itemMaster[$pid]['it_name'];
        }

        $lines[] = array(
            'product_id' => $pid,
            'product_name' => $itemName,
            'plan_type' => $linePlan,
            'unit_hours' => $unitHours,
            'unit_days' => svb_unit_days_by_plan_and_hours($linePlan, $unitHours),
            'unit_price' => $unitPrice,
            'qty' => $qty,
            'line_amount' => $lineAmount,
            'line_hours' => $lineHours,
            'option_spec' => isset($r['option_spec']) ? (string)$r['option_spec'] : '',
        );

        $sumAmount += $lineAmount;
        $sumHours += $lineHours;
        $sumQty += $qty;
    }

    if ($sumAmount <= 0) {
        return array();
    }

    $first = $lines[0];
    return array(
        'schema_ver' => 1,
        'source' => 'detail_rows',
        'mb_id' => (string)$mbId,
        'product_id' => (string)$first['product_id'],
        'product_ids' => array_values(array_unique(array_map(function ($r) {
            return (string)$r['product_id'];
        }, $lines))),
        'plan_type' => (string)$first['plan_type'],
        'unit_hours' => (int)$first['unit_hours'],
        'unit_days' => (float)$first['unit_days'],
        'unit_price' => (int)$first['unit_price'],
        'qty' => (int)$sumQty,
        'total_amount' => (int)$sumAmount,
        'currency' => (string)$currency,
        'total_hours' => (int)$sumHours,
        'total_days' => round($sumHours / 24, 6),
        'selected_count' => count($lines),
        'lines' => $lines,
        'selected_product_ids' => array_values(array_map(function ($r) {
            return (string)$r['product_id'];
        }, $lines)),
        'selected_order_hours' => array_values(array_map(function ($r) {
            return (int)$r['line_hours'];
        }, $lines)),
        'selected_item_prices' => array_values(array_map(function ($r) {
            return (int)$r['line_amount'];
        }, $lines)),
        'selected_quantities' => array_values(array_map(function ($r) {
            return (int)$r['qty'];
        }, $lines)),
    );
}

function svb_fetch_items_by_ids($ids)
{
    global $g5;

    if (!isset($g5['g5_shop_item_table']) || !is_array($ids) || count($ids) < 1) {
        return array();
    }

    $in = array();
    foreach ($ids as $id) {
        $in[] = "'" . svb_sql_escape($id) . "'";
    }

    $itemTable = $g5['g5_shop_item_table'];
    $sql = "SELECT it_id, it_name, it_price, it_buy_min_qty
              FROM {$itemTable}
             WHERE it_id IN (" . implode(',', $in) . ")
               AND it_use='1'";

    $res = sql_query($sql, false);
    if (!$res) {
        return array();
    }

    $byId = array();
    while ($row = sql_fetch_array($res)) {
        $id = isset($row['it_id']) ? trim((string)$row['it_id']) : '';
        if ($id === '') {
            continue;
        }
        $byId[$id] = array(
            'it_id' => $id,
            'it_name' => isset($row['it_name']) ? trim((string)$row['it_name']) : ('VPN-' . $id),
            'it_price' => isset($row['it_price']) ? (int)$row['it_price'] : 0,
            'it_buy_min_qty' => isset($row['it_buy_min_qty']) ? (float)$row['it_buy_min_qty'] : 0,
        );
    }

    $out = array();
    foreach ($ids as $id) {
        if (isset($byId[$id])) {
            $out[] = $byId[$id];
        }
    }

    return $out;
}

function svb_ensure_order_table()
{
    $sql = "CREATE TABLE IF NOT EXISTS vpn_native_buy_orders (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        order_id VARCHAR(60) NOT NULL,
        mb_id VARCHAR(100) NOT NULL,
        it_id VARCHAR(64) NOT NULL DEFAULT '',
        it_name VARCHAR(255) NOT NULL DEFAULT '',
        product_ids TEXT NULL,
        plan_type VARCHAR(20) NOT NULL DEFAULT 'hour',
        unit_hours INT NOT NULL DEFAULT 1,
        unit_days DECIMAL(16,6) NOT NULL DEFAULT 0,
        unit_price INT NOT NULL DEFAULT 0,
        qty INT NOT NULL DEFAULT 1,
        total_amount INT NOT NULL DEFAULT 0,
        point_price INT NOT NULL DEFAULT 0,
        currency VARCHAR(12) NOT NULL DEFAULT 'KRW',
        approval_token VARCHAR(120) NOT NULL,
        pg_tx_id VARCHAR(120) NULL,
        approved_amount INT NULL,
        idempotency_key VARCHAR(120) NULL,
        status VARCHAR(20) NOT NULL DEFAULT 'PREPARED',
        created_at DATETIME NOT NULL,
        prepared_at DATETIME NULL,
        expires_at DATETIME NULL,
        approved_at DATETIME NULL,
        payload_json MEDIUMTEXT NULL,
        PRIMARY KEY (id),
        UNIQUE KEY uq_order_id (order_id),
        KEY idx_mb_status (mb_id, status)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";

    if (!sql_query($sql, false)) {
        return false;
    }

    sql_query("ALTER TABLE vpn_native_buy_orders ADD UNIQUE KEY uq_order_pg_tx_id (order_id, pg_tx_id)", false);
    sql_query("ALTER TABLE vpn_native_buy_orders ADD UNIQUE KEY uq_idempotency_key (idempotency_key)", false);

    // [FIX 2026-05-07] 신규/연장 분기 및 멱등성 컬럼 보강 (있으면 무시됨)
    sql_query("ALTER TABLE vpn_native_buy_orders ADD COLUMN is_renewal TINYINT(1) NOT NULL DEFAULT 0", false);
    sql_query("ALTER TABLE vpn_native_buy_orders ADD COLUMN target_profile_id VARCHAR(120) NULL", false);
    sql_query("ALTER TABLE vpn_native_buy_orders ADD COLUMN target_profile_no INT NULL", false);
    sql_query("ALTER TABLE vpn_native_buy_orders ADD COLUMN target_profile_old_expiry DATETIME NULL", false);

    return true;
}

function svb_payload_from_prepare($mbId, $ids, $items, $qtyList, $plan, $currency, $planList = array(), $unitHoursList = array())
{
    $lines = array();
    $sumAmount = 0;
    $sumHours = 0;
    $sumQty = 0;

    foreach ($items as $idx => $it) {
        $q = isset($qtyList[$idx]) ? max(1, (int)$qtyList[$idx]) : 1;
        $unitPrice = max(0, (int)$it['it_price']);
        if ($unitPrice <= 0) {
            return array();
        }

        $linePlan = isset($planList[$idx]) ? svb_normalize_plan($planList[$idx]) : svb_normalize_plan($plan);
        $explicitHours = isset($unitHoursList[$idx]) ? (int)$unitHoursList[$idx] : 0;
        $unitHours = $explicitHours > 0 ? $explicitHours : svb_item_unit_hours($it, $linePlan);
        $lineAmount = $unitPrice * $q;
        $lineHours = $unitHours * $q;

        $line = array(
            'product_id' => (string)$it['it_id'],
            'product_name' => (string)$it['it_name'],
            'plan_type' => $linePlan,
            'unit_hours' => (int)$unitHours,
            'unit_days' => svb_unit_days_by_plan_and_hours($linePlan, $unitHours),
            'unit_price' => (int)$unitPrice,
            'qty' => (int)$q,
            'line_amount' => (int)$lineAmount,
            'line_hours' => (int)$lineHours,
        );

        $lines[] = $line;
        $sumAmount += $lineAmount;
        $sumHours += $lineHours;
        $sumQty += $q;
    }

    if (count($lines) < 1 || $sumAmount <= 0) {
        return array();
    }

    $first = $lines[0];
    return array(
        'schema_ver' => 1,
        'mb_id' => (string)$mbId,
        'product_id' => (string)$first['product_id'],
        'product_ids' => $ids,
        'plan_type' => svb_normalize_plan($plan),
        'unit_hours' => (int)$first['unit_hours'],
        'unit_days' => (float)$first['unit_days'],
        'unit_price' => (int)$first['unit_price'],
        'qty' => (int)$sumQty,
        'total_amount' => (int)$sumAmount,
        'currency' => (string)$currency,
        'total_hours' => (int)$sumHours,
        'total_days' => round($sumHours / 24, 6),
        'selected_count' => count($lines),
        'lines' => $lines,
        'selected_product_ids' => array_values(array_map(function ($r) {
            return (string)$r['product_id'];
        }, $lines)),
        'selected_order_hours' => array_values(array_map(function ($r) {
            return (int)$r['line_hours'];
        }, $lines)),
        'selected_item_prices' => array_values(array_map(function ($r) {
            return (int)$r['line_amount'];
        }, $lines)),
        'selected_quantities' => array_values(array_map(function ($r) {
            return (int)$r['qty'];
        }, $lines)),
    );
}

function svb_public_error_message($m)
{
    $safe = array(
        '주문을 찾을 수 없습니다.',
        '승인 토큰이 올바르지 않습니다.',
        '준비된 주문 유효시간이 만료되었습니다.',
        '주문 금액 검증에 실패했습니다.',
        '이미 승인된 주문입니다.',
        '승인 처리 중 오류가 발생했습니다.',
        '포인트가 부족합니다.',
    );
    return in_array((string)$m, $safe, true) ? $m : '승인 처리 중 오류가 발생했습니다.';
}

/**
 * [FIX 2026-05-07] 연장(renewal) 대상 프로필의 현재 만료시각을 조회한다.
 * vpnid_list 에서 mb_id + (no | vpn_user_id | server_name) 으로 식별한다.
 * 없으면 빈 문자열 반환.
 */
function svb_lookup_target_profile_expiry($mbId, $targetId)
{
    $mbId = trim((string)$mbId);
    $targetId = trim((string)$targetId);
    if ($mbId === '' || $targetId === '') {
        return '';
    }
    $safeMb = svb_sql_escape($mbId);
    $safeTarget = svb_sql_escape($targetId);

    $where = array();
    if (ctype_digit($targetId)) {
        $where[] = "no='" . (int)$targetId . "'";
    }
    $where[] = "vpn_user_id='{$safeTarget}'";
    $where[] = "server_name='{$safeTarget}'";

    $sql = "SELECT vpn_id_expire FROM vpnid_list
             WHERE (mb_id='{$safeMb}' OR remark='{$safeMb}' OR member_id='{$safeMb}')
               AND (" . implode(' OR ', $where) . ")
             ORDER BY vpn_id_expire DESC LIMIT 1";
    $row = sql_fetch($sql, false);
    return isset($row['vpn_id_expire']) ? (string)$row['vpn_id_expire'] : '';
}

/**
 * [FIX 2026-05-07] 승인 후 신규/연장된 카트 라인의 만료시각을 조회한다.
 */
function svb_lookup_new_expiry($shopOdId, $mbId)
{
    global $g5;
    if (!isset($g5['g5_shop_cart_table'])) {
        return '';
    }
    $safeOd = svb_sql_escape($shopOdId);
    $safeMb = svb_sql_escape($mbId);
    $row = sql_fetch("SELECT MAX(extension_of_period_to_datetime) AS new_expiry
                        FROM {$g5['g5_shop_cart_table']}
                       WHERE od_id='{$safeOd}'
                         AND mb_id='{$safeMb}'", false);
    return isset($row['new_expiry']) ? (string)$row['new_expiry'] : '';
}

/**
 * [FIX 2026-05-08] approve 후 vpnid_list 에 (od_id + it_id + mb_id) 기준 row 가 등록됐는지
 *   라인별로 확인하고, 첫 vpn `no` (PK) 를 반환한다.
 *   반환: { it_id => array('inserted' => bool, 'vpn_no' => int, 'count' => int) }
 */
function svb_verify_vpnid_list_rows($shopOdId, $mbId, $itIds)
{
    $result = array();
    if (!is_array($itIds) || count($itIds) < 1 || $shopOdId === '' || $mbId === '') {
        return $result;
    }
    $safeOd = svb_sql_escape($shopOdId);
    $safeMb = svb_sql_escape($mbId);

    $exists = sql_fetch("SHOW TABLES LIKE 'vpnid_list'", false);
    if (!is_array($exists) || count($exists) < 1) {
        foreach ($itIds as $itId) {
            $result[(string)$itId] = array('inserted' => false, 'vpn_no' => 0, 'count' => 0);
        }
        return $result;
    }
    $colCheck = sql_fetch("SHOW COLUMNS FROM vpnid_list LIKE 'od_id'", false);
    $hasOdId = is_array($colCheck) && isset($colCheck['Field']);

    foreach ($itIds as $itId) {
        $itId = (string)$itId;
        $row = array('inserted' => false, 'vpn_no' => 0, 'count' => 0);
        if ($itId !== '' && $hasOdId) {
            $safeIt = svb_sql_escape($itId);
            $rs = sql_fetch("SELECT MIN(`no`) AS first_no, COUNT(*) AS cnt
                               FROM vpnid_list
                              WHERE od_id='{$safeOd}'
                                AND mb_id='{$safeMb}'
                                AND it_name IN (
                                    SELECT it_name FROM g5_shop_item WHERE it_id='{$safeIt}'
                                )", false);
            $cnt = isset($rs['cnt']) ? (int)$rs['cnt'] : 0;
            if ($cnt > 0) {
                $row['inserted'] = true;
                $row['count'] = $cnt;
                $row['vpn_no'] = isset($rs['first_no']) ? (int)$rs['first_no'] : 0;
            } else {
                // 폴백: it_name 매칭이 어려운 경우 od_id 단독으로 카운트만 확인
                $rs2 = sql_fetch("SELECT MIN(`no`) AS first_no, COUNT(*) AS cnt
                                    FROM vpnid_list
                                   WHERE od_id='{$safeOd}'
                                     AND mb_id='{$safeMb}'", false);
                $cnt2 = isset($rs2['cnt']) ? (int)$rs2['cnt'] : 0;
                if ($cnt2 > 0) {
                    $row['inserted'] = true;
                    $row['count'] = $cnt2;
                    $row['vpn_no'] = isset($rs2['first_no']) ? (int)$rs2['first_no'] : 0;
                }
            }
        }
        $result[$itId] = $row;
    }
    return $result;
}

// [FIX 2026-05-07] API revision tag (앱과의 응답 계약 버전)
if (!defined('SVB_API_REVISION')) {
    define('SVB_API_REVISION', '2026-05-07-r1');
}
if (!defined('SVB_DEFAULT_CA_ID')) {
    define('SVB_DEFAULT_CA_ID', '302010'); // IKEv2-VPN-Client 기본 카테고리
}

function svb_request_id()
{
    static $id = '';
    if ($id === '') {
        $id = 'rq_' . substr(hash('sha256', uniqid('', true) . mt_rand()), 0, 12);
    }
    return $id;
}

function svb_mask_mb($mbId)
{
    $mbId = (string)$mbId;
    if ($mbId === '') {
        return '';
    }
    $h = substr(hash('sha256', $mbId), 0, 8);
    $head = mb_substr($mbId, 0, 2);
    return $head . '***#' . $h;
}

function svb_log($action, $context = array())
{
    $base = array(
        'request_id' => svb_request_id(),
        'endpoint' => 'strongswan_vpnid_buy.php',
        'action' => (string)$action,
        'ip' => isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : '',
        'ua' => isset($_SERVER['HTTP_USER_AGENT']) ? substr((string)$_SERVER['HTTP_USER_AGENT'], 0, 120) : '',
    );
    if (function_exists('vpnlink_native_purchase_log')) {
        vpnlink_native_purchase_log('vpnid_buy:' . $action, array_merge($base, is_array($context) ? $context : array()));
    } else {
        @error_log('[SVB] ' . json_encode(array_merge($base, is_array($context) ? $context : array()), JSON_UNESCAPED_UNICODE));
    }
}

/**
 * [FIX 2026-05-08] detail_rows 파싱 결과를 진단용으로 안전하게 dump.
 *   - 파서가 __error sentinel 을 반환한 경우도 그대로 노출
 *   - 정상 row 는 product_id/qty/unit_hours/unit_price/line_hours/line_amount 만 추출
 */
function svb_dump_detail_rows($detailRows)
{
    if (!is_array($detailRows)) {
        return array('row_count' => 0, 'rows' => array(), 'parse_error' => 'not_array');
    }
    if (isset($detailRows['__error'])) {
        return array(
            'row_count' => 0,
            'rows' => array(),
            'parse_error' => (string)$detailRows['__error'],
            'parse_error_line' => isset($detailRows['__line']) ? (string)$detailRows['__line'] : '',
            'invalid_row_index' => isset($detailRows['__invalid_row_index']) ? (int)$detailRows['__invalid_row_index'] : -1,
        );
    }
    $rows = array();
    foreach ($detailRows as $idx => $r) {
        if (!is_array($r)) continue;
        $rows[] = array(
            'i' => (int)$idx,
            'product_id' => isset($r['product_id']) ? (string)$r['product_id'] : '',
            'qty' => isset($r['qty']) ? (int)$r['qty'] : 0,
            'unit_hours' => isset($r['unit_hours']) ? (int)$r['unit_hours'] : 0,
            'unit_price' => isset($r['unit_price']) ? (int)$r['unit_price'] : 0,
            'line_hours' => isset($r['line_hours']) ? (int)$r['line_hours'] : 0,
            'line_amount' => isset($r['line_amount']) ? (int)$r['line_amount'] : 0,
        );
    }
    return array('row_count' => count($rows), 'rows' => $rows, 'parse_error' => '');
}

function svb_normalize_ca_id($ca)
{
    $ca = preg_replace('/[^0-9]/', '', (string)$ca);
    if ($ca === '') {
        return SVB_DEFAULT_CA_ID;
    }
    return $ca;
}

/**
 * 카테고리 + 옵션 조건으로 g5_shop_item 검색 WHERE 빌더.
 * - it_use='1' 강제 (사용중 플래그)
 * - it_soldout<>'1' 강제 (품절 제외) - [FIX 2026-05-07] 품절 상품 노출 차단
 * - it_stock_qty/it_use_stock 컬럼이 존재하면 재고 0 이하 제외
 */
function svb_build_item_where($caId)
{
    global $g5;
    $safeCa = svb_sql_escape($caId);
    $where = "(ca_id LIKE '{$safeCa}%' OR ca_id2 LIKE '{$safeCa}%' OR ca_id3 LIKE '{$safeCa}%')"
        . " AND it_use='1'"
        . " AND COALESCE(it_soldout, '0') <> '1'";

    // 재고관리 사용 시점에만 재고 컬럼으로 추가 필터링
    if (function_exists('vpnlink_native_purchase_table_columns') && isset($g5['g5_shop_item_table'])) {
        $cols = vpnlink_native_purchase_table_columns($g5['g5_shop_item_table']);
        $hasStockQty = isset($cols['it_stock_qty']);
        $hasUseStock = isset($cols['it_use_stock']);
        if ($hasStockQty && $hasUseStock) {
            // it_use_stock='1' 인 상품만 재고 검사
            $where .= " AND (COALESCE(it_use_stock,'0')<>'1' OR COALESCE(it_stock_qty,0) > 0)";
        } elseif ($hasStockQty) {
            $where .= " AND COALESCE(it_stock_qty, 999999) > 0";
        }
    }

    return $where;
}

function svb_normalize_sort($sort, $sortodr)
{
    $allow = array('it_id', 'it_price', 'it_name', 'it_update_time', 'it_time', 'it_sum_qty');
    $sort = preg_replace('/[^A-Za-z0-9_]/', '', (string)$sort);
    if (!in_array($sort, $allow, true)) {
        $sort = 'it_price';
    }
    $sortodr = strtolower(preg_replace('/[^A-Za-z]/', '', (string)$sortodr));
    if ($sortodr !== 'asc' && $sortodr !== 'desc') {
        $sortodr = 'asc';
    }
    return array($sort, $sortodr);
}

$action = strtolower(svb_req('action', 'prepare'));
$mbIdInput = svb_req('mb_id', svb_req('username', ''));
$mbPwInput = svb_req('mb_password', svb_req('password', ''));
$svbStartedAt = microtime(true);

svb_log($action, array(
    'phase' => 'request_in',
    'mb_id_masked' => svb_mask_mb($mbIdInput),
    'has_password' => $mbPwInput !== '',
    'ca_id' => svb_req('ca_id', ''),
    'plan' => svb_req('plan', svb_req('plan_type', '')),
    'page' => svb_req('page', ''),
    'per_page' => svb_req('per_page', ''),
    'sort' => svb_req('sort', ''),
    'sortodr' => svb_req('sortodr', ''),
));

// list_products / list_types 는 인증 없이 허용 (게스트 상품 브라우징 지원)
$svbGuestBrowse = in_array($action, array('list_products', 'list_types', 'list_grades'));
if ($svbGuestBrowse) {
    $mbId = '';
} else {
    if ($mbIdInput === '' || $mbPwInput === '') {
        svb_log($action, array('phase' => 'auth_fail', 'reason' => 'missing_credentials'));
        svb_fail_with_prepare_debug($action, 'AUTH_REQUIRED', '로그인이 필요합니다.', 'missing_credentials');
    }

    if (!svb_ensure_order_table()) {
        svb_fail_with_prepare_debug($action, 'ORDER_TABLE_INIT_FAIL', '주문 저장소 초기화에 실패했습니다.', 'order_table_init_fail');
    }

    $member = svb_member_by_id_or_email($mbIdInput);
    if (empty($member['mb_id'])) {
        svb_log($action, array('phase' => 'auth_fail', 'reason' => 'no_member', 'mb_id_masked' => svb_mask_mb($mbIdInput)));
        svb_fail_with_prepare_debug($action, 'AUTH_INVALID', '회원 정보를 찾을 수 없습니다.', 'no_member');
    }
    if (!svb_check_login_password($member, $mbPwInput)) {
        svb_log($action, array('phase' => 'auth_fail', 'reason' => 'bad_password', 'mb_id_masked' => svb_mask_mb($member['mb_id'])));
        svb_fail_with_prepare_debug($action, 'AUTH_INVALID', '아이디 또는 비밀번호가 올바르지 않습니다.', 'bad_password');
    }

    $mbId = (string)$member['mb_id'];

    // [FIX 2026-05-07] 세션 사용자와 요청 사용자 일치 검증 (제공된 경우에만)
    $sessionMbId = isset($_SESSION['ss_mb_id']) ? trim((string)$_SESSION['ss_mb_id']) : '';
    if ($sessionMbId !== '' && $sessionMbId !== $mbId) {
        svb_log($action, array(
            'phase' => 'auth_fail',
            'reason' => 'user_mismatch',
            'session_mb_masked' => svb_mask_mb($sessionMbId),
            'request_mb_masked' => svb_mask_mb($mbId),
        ));
        svb_fail_with_prepare_debug($action, 'USER_MISMATCH', '세션 사용자와 요청 사용자가 일치하지 않습니다.', 'user_mismatch');
    }

    svb_log($action, array('phase' => 'auth_ok', 'mb_id_masked' => svb_mask_mb($mbId)));
}

if ($action === 'prepare') {
    $rawProductId = svb_req('product_id', '');
    $rawCaId = svb_req('ca_id', '');
    $ids = svb_parse_product_ids(svb_req('product_id', ''), svb_req('product_ids', ''));
    $detailRowsRaw = svb_req('detail_rows', '');
    $detailRows = svb_parse_detail_rows($detailRowsRaw);
    $detailRowsClientSent = ($detailRowsRaw !== '');
    $rawPlan = svb_req('plan_type', svb_req('plan', 'hour'));
    $rawPlanType = svb_req('plan_type', '');
    $rawAmount = svb_to_int(svb_req('amount', '0'), 0);

    // [FIX 2026-05-08] 진단 로그/응답에 공통 사용할 debug 블록 빌더
    $svbPrepDebug = function ($extra = array()) use (
        &$ids, &$rawPlan, &$rawPlanType, &$rawAmount, &$detailRows, &$detailRowsRaw, &$mbId, &$rawProductId, &$rawCaId
    ) {
        $dump = svb_dump_detail_rows($detailRows);
        $expectedAmount = 0;
        if (isset($dump['rows']) && is_array($dump['rows'])) {
            foreach ($dump['rows'] as $rr) {
                if (is_array($rr) && isset($rr['line_amount'])) {
                    $expectedAmount += (int)$rr['line_amount'];
                }
            }
        }
        $base = array(
            'mb_id' => (string)$mbId,
            'ca_id' => (string)$rawCaId,
            'product_id' => (string)$rawProductId,
            'plan' => (string)$rawPlan,
            'plan_type' => (string)$rawPlanType,
            'product_ids' => array_values($ids),
            'amount' => (int)$rawAmount,
            'request_amount' => (int)$rawAmount,
            'expected_amount' => (int)$expectedAmount,
            'detail_rows_len' => strlen((string)$detailRowsRaw),
            'row_count' => (int)$dump['row_count'],
            'rows' => $dump['rows'],
            'parse_error' => $dump['parse_error'],
        );
        if (isset($dump['parse_error_line'])) {
            $base['parse_error_line'] = $dump['parse_error_line'];
        }
        if (isset($dump['invalid_row_index'])) {
            $base['invalid_row_index'] = (int)$dump['invalid_row_index'];
        }
        if (is_array($extra)) {
            $base = array_merge($base, $extra);
        }
        return $base;
    };

    $svbPrepFail = function ($code, $reason, $extraDebug = array(), $message = '') use ($svbPrepDebug) {
        $code = trim((string)$code);
        $reason = trim((string)$reason);
        if ($code === '') {
            $code = 'INVALID_PLAN_OR_ROWS';
        }
        if ($reason === '') {
            $reason = strtolower($code);
        }
        $dbg = $svbPrepDebug(array_merge(array('fail_code' => $code, 'fail_reason' => $reason), is_array($extraDebug) ? $extraDebug : array()));
        svb_log('prepare', array_merge(array(
            'phase' => 'fail',
            'fail_code' => $code,
            'fail_reason' => $reason,
            'code' => $code,
            'reason' => $reason,
        ), $dbg));
        if ($message === '') {
            $message = '주문 정보가 올바르지 않습니다. 상품/기간을 다시 선택해 주세요.';
        }
        svb_fail('prepare', $code, $message, array('debug' => $dbg));
    };

    // [FIX 2026-05-08] 파서가 명시적 에러를 반환하면 즉시 fail (master fallback 금지)
    if (is_array($detailRows) && isset($detailRows['__error'])) {
        $errCode = (string)$detailRows['__error'];
        if ($errCode === '') {
            $errCode = 'ROW_PARSE_FAIL';
        }
        $svbPrepFail($errCode, 'detail_rows_invalid');
    }

    // [FIX 2026-05-08] detail_rows 가 유효하면 그 안의 product_id 도 ids 에 추가
    if (count($detailRows) > 0) {
        $extraIds = array();
        foreach ($detailRows as $r) {
            $pid = preg_replace('/[^A-Za-z0-9_\-]/', '', trim((string)$r['product_id']));
            // fee_ 접두사 rows는 VPN 상품이 아니므로 DB 검증 제외
            if ($pid !== '' && substr($pid, 0, 4) !== 'fee_') {
                $extraIds[] = $pid;
            }
        }
        if (count($extraIds) > 0) {
            $ids = array_values(array_unique(array_merge($ids, $extraIds)));
        }
    }

    if (count($ids) < 1) {
        // 상품 ID가 없는 주문: 로그 기록 후 사용자 친화적 메시지 반환
        $_svbLogMb = function_exists('sql_real_escape_string') ? sql_real_escape_string($mbId) : addslashes($mbId);
        sql_query("INSERT INTO vpnlink_invalid_product_log (mb_id, vpn_it_name, shop_it_id, action_type, user_message) VALUES ('$_svbLogMb', '', '', 'order_blocked', '상품 ID 없음 - 주문 차단')", false);
        $svbPrepFail('PRODUCT_NOT_FOUND', 'no_product_id', array(), '주문할 수 없는 상품입니다. 고객센터로 문의해 주세요.');
    }

    $items = svb_fetch_items_by_ids($ids);
    if (count($items) !== count($ids)) {
        $inspect = svb_inspect_items_for_prepare($ids);
        $errCode = 'PRODUCT_NOT_FOUND';
        if (count($inspect['not_sale']) > 0) {
            $errCode = 'PRODUCT_NOT_SALE';
        } elseif (count($inspect['not_found']) > 0) {
            $errCode = 'PRODUCT_NOT_FOUND';
        }
        // 존재하지 않는 상품번호: 로그 기록
        if (count($inspect['not_found']) > 0) {
            $_svbLogMb2 = function_exists('sql_real_escape_string') ? sql_real_escape_string($mbId) : addslashes($mbId);
            foreach ($inspect['not_found'] as $_nfId) {
                $_nfSafe = function_exists('sql_real_escape_string') ? sql_real_escape_string((string)$_nfId) : addslashes((string)$_nfId);
                sql_query("INSERT INTO vpnlink_invalid_product_log (mb_id, vpn_it_name, shop_it_id, action_type, user_message) VALUES ('$_svbLogMb2', '', '$_nfSafe', 'order_blocked', '상품번호 $_nfSafe DB에 없음 - 주문 차단')", false);
            }
        }
        $_svbBlockMsg = count($inspect['not_found']) > 0
            ? '주문할 수 없는 상품입니다. 고객센터로 문의해 주세요.'
            : '현재 판매 중이 아닌 상품이 포함되어 있습니다. 고객센터로 문의해 주세요.';
        $svbPrepFail($errCode, 'item_lookup_mismatch', array(
            'requested_ids' => $ids,
            'found_count' => count($items),
            'not_found_ids' => $inspect['not_found'],
            'not_sale_ids' => $inspect['not_sale'],
        ), $_svbBlockMsg);
    }

    // 마스터 인덱스 (detail_rows 경로에서 it_name 보강용)
    $itemMasterIdx = array();
    foreach ($items as $itRow) {
        if (!empty($itRow['it_id'])) {
            $itemMasterIdx[(string)$itRow['it_id']] = $itRow;
        }
    }

    // [FIX 2026-05-08] plan 'all' 명시 허용. detail_rows 의 unit_hours 가 진실의 원천이며,
    //   'all' 은 단일 plan 강제를 비활성화한다는 신호로 처리한다.
    $plan = svb_normalize_plan($rawPlan);
    $currency = svb_currency(svb_req('currency', 'KRW'));

    $qtyCommon = max(1, svb_to_int(svb_req('qty', '1'), 1));
    $qtyList = svb_parse_qty_list(svb_req('selected_quantities', ''), count($items));
    if (count($items) === 1 && svb_req('selected_quantities', '') === '') {
        $qtyList[0] = $qtyCommon;
    }

    // [FIX 2026-05-07] 라인별 plan/unit_hours 혼합 주문 지원
    $planList = svb_parse_plan_list(svb_req('selected_plans', ''), count($items), $plan);
    $unitHoursList = svb_parse_unit_hours_list(svb_req('selected_unit_hours', ''), count($items));

    // [FIX 2026-05-07] 신규/연장 분기
    $isRenewalRaw = strtolower(trim((string)svb_req('is_renewal', '0')));
    $isRenewal = in_array($isRenewalRaw, array('1', 'true', 'yes', 'y', 'on'), true) ? 1 : 0;
    $targetProfileId = trim((string)svb_req('target_profile_id', ''));
    $targetProfileId = preg_replace('/[^A-Za-z0-9_\-\.@]/', '', $targetProfileId);
    if (strlen($targetProfileId) > 120) {
        $targetProfileId = substr($targetProfileId, 0, 120);
    }
    $targetProfileNo = svb_to_int(svb_req('target_profile_no', '0'), 0);

    // [FIX 2026-05-07] prepare 단계 idempotency_key 수용
    $prepIdemKey = trim((string)svb_req('idempotency_key', ''));
    $prepIdemKey = preg_replace('/[^A-Za-z0-9_\-:\.]/', '', $prepIdemKey);
    if (strlen($prepIdemKey) > 120) {
        $prepIdemKey = substr($prepIdemKey, 0, 120);
    }

    // [FIX 2026-05-07] detail_rows 가 유효하면 그것을 신뢰 기준으로 payload 구성
    // (요금제 변경 후 클라이언트 단가/시간/금액 그대로 반영)
    // [FIX 2026-05-08] 클라이언트가 detail_rows 를 보냈는데 파싱 후 빈 배열이면
    //   master fallback 으로 1시간이 적용되는 사고를 방지하기 위해 즉시 fail.
    $payloadSource = 'master';
    if (count($detailRows) > 0) {
        $payload = svb_payload_from_detail_rows($mbId, $detailRows, $itemMasterIdx, $plan, $currency);
        if (count($payload) > 0) {
            $payloadSource = 'detail_rows';
        } else {
            $svbPrepFail('ROW_PARSE_FAIL', 'detail_rows_payload_empty');
        }
    } elseif ($detailRowsClientSent) {
        $svbPrepFail('ROW_PARSE_FAIL', 'detail_rows_empty_after_parse');
    } else {
        $payload = svb_payload_from_prepare($mbId, $ids, $items, $qtyList, $plan, $currency, $planList, $unitHoursList);
    }

    if (count($payload) < 1) {
        $svbPrepFail('ROW_PARSE_FAIL', 'invalid_snapshot', array('source' => $payloadSource));
    }

    $requestedAmount = max(0, $rawAmount);
    if ($requestedAmount > 0 && $requestedAmount !== (int)$payload['total_amount']) {
        // [FIX 2026-05-08] prepare retry(무 detail_rows) 경로는 금액 오판정 방지를 위해 관용 처리.
        // detail_rows 기준 검증에서는 기존과 동일하게 엄격 비교한다.
        if ($payloadSource === 'master' && !$detailRowsClientSent) {
            svb_log('prepare', array(
                'phase' => 'warn',
                'reason' => 'amount_mismatch_tolerated_master_retry',
                'request_amount' => $requestedAmount,
                'expected_amount' => (int)$payload['total_amount'],
            ));
        } else {
            $svbPrepFail('AMOUNT_MISMATCH', 'amount_mismatch', array(
                'source' => $payloadSource,
                'request_amount' => $requestedAmount,
                'expected_amount' => (int)$payload['total_amount'],
                'received_amount' => $requestedAmount,
            ));
        }
    }

    // detail_rows 가 검증되지 않은 채 들어왔는데 amount 와 일치하지 않으면 명확한 코드로 실패
    if (count($detailRows) > 0 && $payloadSource !== 'detail_rows') {
        svb_log('prepare', array(
            'phase' => 'warn',
            'reason' => 'detail_rows_parse_failed_fallback_master',
            'detail_rows_len' => strlen((string)$detailRowsRaw),
        ));
    }

    // [FIX 2026-05-07] 연장 대상의 현재 만료시각을 prepare 시점에 고정 캡처(불변)
    $targetOldExpiry = '';
    if ($isRenewal && ($targetProfileId !== '' || $targetProfileNo > 0)) {
        $lookupKey = $targetProfileId !== '' ? $targetProfileId : (string)$targetProfileNo;
        $targetOldExpiry = svb_lookup_target_profile_expiry($mbId, $lookupKey);
    }

    $orderId = svb_generate_order_id();
    $approvalToken = svb_generate_token($orderId, $mbId);
    $preparedAt = date('Y-m-d H:i:s');
    $expiresAt = date('Y-m-d H:i:s', time() + (15 * 60));

    $payload['order_id'] = $orderId;
    $payload['approval_token'] = $approvalToken;
    $payload['prepared_at'] = $preparedAt;
    $payload['expires_at'] = $expiresAt;
    $payload['source'] = $payloadSource;
    $payload['detail_rows_raw'] = (string)$detailRowsRaw;
    $payload['is_renewal'] = $isRenewal ? '1' : '0';
    $payload['target_profile_id'] = $targetProfileId;
    $payload['target_profile_no'] = (int)$targetProfileNo;
    $payload['target_profile_old_expiry'] = $targetOldExpiry;
    if ($prepIdemKey !== '') {
        $payload['idempotency_key'] = $prepIdemKey;
    }

    // product_ids 표기 통일(문자열 list)
    $idListForCol = is_array($payload['product_ids'])
        ? implode(',', array_map('strval', $payload['product_ids']))
        : implode(',', $ids);

    $safeOrderId = svb_sql_escape($orderId);
    $safeMb = svb_sql_escape($mbId);
    $safeItId = svb_sql_escape((string)$payload['product_id']);
    $firstName = '';
    if (isset($itemMasterIdx[(string)$payload['product_id']]['it_name'])) {
        $firstName = (string)$itemMasterIdx[(string)$payload['product_id']]['it_name'];
    } elseif (!empty($items[0]['it_name'])) {
        $firstName = (string)$items[0]['it_name'];
    } else {
        $firstName = 'VPN-' . (string)$payload['product_id'];
    }
    $safeItName = svb_sql_escape($firstName);
    $safeProductIds = svb_sql_escape($idListForCol);
    $safePlan = svb_sql_escape((string)$payload['plan_type']);
    $safeCurrency = svb_sql_escape($currency);
    $safeToken = svb_sql_escape($approvalToken);
    $safePayload = svb_sql_escape(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    $safeTargetId = svb_sql_escape($targetProfileId);
    $safeOldExpirySql = ($targetOldExpiry !== '' && strtotime($targetOldExpiry) !== false)
        ? "'" . svb_sql_escape($targetOldExpiry) . "'"
        : 'NULL';
    $safePrepIdemSql = $prepIdemKey !== '' ? "'" . svb_sql_escape($prepIdemKey) . "'" : 'NULL';

    $sql = "INSERT INTO vpn_native_buy_orders
            (order_id, mb_id, it_id, it_name, product_ids, plan_type, unit_hours, unit_days, unit_price, qty,
             total_amount, point_price, currency, approval_token, status, created_at, prepared_at, expires_at, payload_json,
             is_renewal, target_profile_id, target_profile_no, target_profile_old_expiry, idempotency_key)
            VALUES
            ('{$safeOrderId}', '{$safeMb}', '{$safeItId}', '{$safeItName}', '{$safeProductIds}', '{$safePlan}',
             " . (int)$payload['unit_hours'] . ", " . (float)$payload['unit_days'] . ", " . (int)$payload['unit_price'] . ", " . (int)$payload['qty'] . ",
             " . (int)$payload['total_amount'] . ", " . (int)$payload['total_amount'] . ", '{$safeCurrency}', '{$safeToken}', 'PREPARED', NOW(), '{$preparedAt}', '{$expiresAt}', '{$safePayload}',
             " . (int)$isRenewal . ", '{$safeTargetId}', " . (int)$targetProfileNo . ", {$safeOldExpirySql}, {$safePrepIdemSql})";

    if (!sql_query($sql, false)) {
        $svbPrepFail('PREPARE_INSERT_FAIL', 'insert_fail', array(), '주문 준비에 실패했습니다.');
    }

    svb_log('prepare', array(
        'phase' => 'response',
        'order_id' => $orderId,
        'mb_id_masked' => svb_mask_mb($mbId),
        'source' => $payloadSource,
        'product_ids' => is_array($payload['product_ids']) ? implode(',', $payload['product_ids']) : '',
        'qty' => (int)$payload['qty'],
        'unit_hours' => (int)$payload['unit_hours'],
        'unit_price' => (int)$payload['unit_price'],
        'total_amount' => (int)$payload['total_amount'],
        'total_hours' => (int)$payload['total_hours'],
        'currency' => $currency,
        'is_renewal' => (bool)$isRenewal,
    ));

    svb_success('prepare', 'prepared', array(
        'order_id' => $orderId,
        'approval_token' => $approvalToken,
        'amount' => (int)$payload['total_amount'],
        'currency' => $currency,
        'is_renewal' => (bool)$isRenewal,
        'target_profile_id' => $targetProfileId,
        'old_expiry' => $targetOldExpiry,
        'duration_hours' => (int)$payload['total_hours'],
        'duration_days' => (float)round($payload['total_hours'] / 24, 6),
        'expires_at' => $expiresAt,
    ));
}

if ($action === 'approve') {
    $orderId = svb_req('order_id', '');
    $approvalToken = svb_req('approval_token', '');

    if ($orderId === '') {
        svb_log('approve', array('phase' => 'fail', 'reason' => 'missing_order_id'));
        svb_fail('approve', 'MISSING_ORDER_ID', '주문 번호가 필요합니다.');
    }
    if ($approvalToken === '') {
        svb_log('approve', array('phase' => 'fail', 'reason' => 'missing_approval_token', 'order_id' => $orderId));
        svb_fail('approve', 'MISSING_APPROVAL_TOKEN', '승인 토큰이 필요합니다.');
    }

    $pgTxId = svb_req('pg_tx_id', svb_req('tx_id', svb_req('tid', '')));
    $approvedAmount = svb_to_int(svb_req('approved_amount', svb_req('amount', '0')), 0);
    $idempotencyKey = trim((string)svb_req('idempotency_key', ''));
    $idempotencyKey = preg_replace('/[^A-Za-z0-9_\-:\.]/', '', $idempotencyKey);
    if (strlen($idempotencyKey) > 120) {
        $idempotencyKey = substr($idempotencyKey, 0, 120);
    }

    // [FIX 2026-05-08] approve 에서도 detail_rows 를 받을 수 있으며,
    // prepare 재시도(무 detail_rows) 후 approve(detail_rows) 시 approve payload 우선 적용한다.
    $approveDetailRowsRaw = svb_req('detail_rows', '');
    $approveDetailRows = svb_parse_detail_rows($approveDetailRowsRaw);
    $approveFailMeta = array();

    // [FIX 2026-05-07] 클라이언트가 선택 결과를 다시 보낼 수 있음(검증/감사용).
    // selected_rows: CSV 또는 줄바꿈 구분의 product_id 목록 (예: "1,2,3" 혹은 detail_rows 원문).
    // approve 단계에서는 절대 재계산/재검증 기준으로 사용하지 않고, prepare 스냅샷이 진실의 원천.
    // 단, 스냅샷 product_ids 와 다른 경우 로그로 남겨 감사한다.
    $clientSelectedRowsRaw = svb_req('selected_rows', '');
    $clientSelectedCount = svb_to_int(svb_req('selected_count', '0'), 0);
    $clientSelectedProductIds = array();
    if ($clientSelectedRowsRaw !== '') {
        // CSV 우선, 실패 시 detail_rows 형식 시도
        $tokens = preg_split('/[\s,;|\n\r]+/', $clientSelectedRowsRaw);
        foreach ((array)$tokens as $tok) {
            $tok = trim((string)$tok);
            if ($tok === '') continue;
            // detail_rows 줄이면 5번째 컬럼이 product_id
            if (strpos($tok, '|') !== false) {
                $cols = explode('|', $tok);
                if (isset($cols[4]) && ctype_digit(trim($cols[4]))) {
                    $clientSelectedProductIds[] = (int)trim($cols[4]);
                }
            } elseif (ctype_digit($tok)) {
                $clientSelectedProductIds[] = (int)$tok;
            }
        }
        $clientSelectedProductIds = array_values(array_unique($clientSelectedProductIds));
    }

    $acquiredLocks = array();
    $firstDiagRow = array();
    $expectedExpiryBefore = '';
    $expectedExpiryAfter = '';

    sql_query('START TRANSACTION', false);
    try {
        $safeOrderId = svb_sql_escape($orderId);
        $safeMb = svb_sql_escape($mbId);

        $order = sql_fetch("SELECT *
                              FROM vpn_native_buy_orders
                             WHERE order_id='{$safeOrderId}'
                               AND mb_id='{$safeMb}'
                             LIMIT 1
                             FOR UPDATE", false);
        if (empty($order['order_id'])) {
            $approveFailMeta = array(
                'code' => 'ORDER_NOT_FOUND',
                'reason' => 'order_not_found',
                'received_amount' => (int)$approvedAmount,
            );
            throw new Exception('주문을 찾을 수 없습니다.');
        }

        $status = strtoupper(trim((string)$order['status']));
        if ($status === 'APPROVED' || $status === 'PAID') {
            // [FIX 2026-05-07] 멱등 응답 - 기존 결과 그대로 재반환
            $existingShopOd = '';
            $existingOldExpiry = '';
            $existingNewExpiry = '';
            $existingDeducted = isset($order['approved_amount']) && (int)$order['approved_amount'] > 0
                ? (int)$order['approved_amount']
                : (int)$order['total_amount'];
            $mapRow = sql_fetch("SELECT shop_od_id FROM vpn_native_buy_order_map
                                  WHERE order_id='{$safeOrderId}' LIMIT 1", false);
            if (!empty($mapRow['shop_od_id'])) {
                $existingShopOd = (string)$mapRow['shop_od_id'];
                $existingNewExpiry = svb_lookup_new_expiry($existingShopOd, $mbId);
            }
            $existingPayload = array();
            $rawPrev = isset($order['payload_json']) ? trim((string)$order['payload_json']) : '';
            if ($rawPrev !== '') {
                $decodedPrev = json_decode($rawPrev, true);
                if (is_array($decodedPrev)) {
                    $existingPayload = $decodedPrev;
                }
            }
            if (isset($existingPayload['target_profile_old_expiry'])) {
                $existingOldExpiry = (string)$existingPayload['target_profile_old_expiry'];
            }
            $existingDurationHours = isset($existingPayload['total_hours']) ? (int)$existingPayload['total_hours'] : 0;

            // [FIX 2026-05-08] 멱등 응답에도 applied_rows + vpnid_list 등록 확인
            $existingItIds = array();
            if (isset($existingPayload['lines']) && is_array($existingPayload['lines'])) {
                foreach ($existingPayload['lines'] as $ln) {
                    $pid = isset($ln['product_id']) ? (string)$ln['product_id'] : '';
                    if ($pid !== '' && !in_array($pid, $existingItIds, true)) {
                        $existingItIds[] = $pid;
                    }
                }
            }
            $existingVpnidCheck = $existingShopOd !== ''
                ? svb_verify_vpnid_list_rows($existingShopOd, $mbId, $existingItIds)
                : array();

            $existingAppliedRows = array();
            if (isset($existingPayload['lines']) && is_array($existingPayload['lines'])) {
                foreach ($existingPayload['lines'] as $ln) {
                    $pid = isset($ln['product_id']) ? (string)$ln['product_id'] : '';
                    $qtyL = isset($ln['qty']) ? max(1, (int)$ln['qty']) : 1;
                    $unitH = isset($ln['unit_hours']) ? (int)$ln['unit_hours'] : 0;
                    $lineH = isset($ln['line_hours']) ? (int)$ln['line_hours'] : ($unitH * $qtyL);
                    $lineP = isset($ln['line_amount']) ? (int)$ln['line_amount'] : 0;
                    $vinfo = isset($existingVpnidCheck[$pid]) ? $existingVpnidCheck[$pid] : array('inserted' => false, 'vpn_no' => 0);
                    $existingAppliedRows[] = array(
                        'product_id' => $pid,
                        'qty' => $qtyL,
                        'unit_hours' => $unitH,
                        'applied_hours' => $lineH,
                        'applied_price' => $lineP,
                        'vpnid_list_inserted' => !empty($vinfo['inserted']),
                        'vpn_no' => isset($vinfo['vpn_no']) ? (int)$vinfo['vpn_no'] : 0,
                        'profile_id_or_vpn_no' => isset($vinfo['vpn_no']) ? (string)(int)$vinfo['vpn_no'] : '',
                    );
                }
            }

            sql_query('COMMIT', false);
            svb_success('approve', 'already approved', array(
                'code' => 'ALREADY_PROCESSED',
                'order_id' => $orderId,
                'shop_od_id' => $existingShopOd,
                'approved' => true,
                'idempotent' => true,
                'deducted_points' => (int)$existingDeducted,
                'duration_applied_hours' => (int)$existingDurationHours,
                'duration_applied_days' => (float)round($existingDurationHours / 24, 6),
                'old_expiry' => $existingOldExpiry,
                'new_expiry' => $existingNewExpiry,
                'applied_rows' => $existingAppliedRows,
            ));
        }

        if ($status !== 'PREPARED' && $status !== 'PENDING') {
            throw new Exception('승인 처리 중 오류가 발생했습니다.');
        }

        // [FIX 2026-05-07] selected_rows 감사 로그 (스냅샷 기준이 진실. 불일치해도 거절하지 않음)
        if (!empty($clientSelectedProductIds)) {
            $snapshotPids = array();
            $rawSnap = isset($order['payload_json']) ? trim((string)$order['payload_json']) : '';
            if ($rawSnap !== '') {
                $decSnap = json_decode($rawSnap, true);
                if (is_array($decSnap) && isset($decSnap['product_ids']) && is_array($decSnap['product_ids'])) {
                    $snapshotPids = array_values(array_unique(array_map('intval', $decSnap['product_ids'])));
                }
            }
            sort($snapshotPids);
            $clientSorted = $clientSelectedProductIds;
            sort($clientSorted);
            if ($clientSorted !== $snapshotPids) {
                svb_log('approve', array(
                    'phase' => 'audit',
                    'reason' => 'selected_rows_diff',
                    'order_id' => $orderId,
                    'mb_id_masked' => svb_mask_mb($mbId),
                    'client_pids' => $clientSorted,
                    'snapshot_pids' => $snapshotPids,
                    'client_selected_count' => $clientSelectedCount,
                    'note' => 'snapshot is source of truth; client selection ignored',
                ));
            }
        }

        if (!hash_equals((string)$order['approval_token'], $approvalToken)) {
            $approveFailMeta = array(
                'code' => 'TOKEN_MISMATCH',
                'reason' => 'approval_token_invalid',
                'received_amount' => (int)$approvedAmount,
                'product_id' => isset($order['it_id']) ? (string)$order['it_id'] : '',
                'plan_type' => isset($order['plan_type']) ? (string)$order['plan_type'] : '',
            );
            throw new Exception('승인 토큰이 올바르지 않습니다.');
        }

        $expiresAt = trim((string)$order['expires_at']);
        if ($expiresAt !== '' && $expiresAt !== '0000-00-00 00:00:00') {
            $expTs = strtotime($expiresAt);
            if ($expTs !== false && $expTs < time()) {
                $approveFailMeta = array(
                    'code' => 'ORDER_EXPIRED',
                    'reason' => 'prepared_order_expired',
                    'received_amount' => (int)$approvedAmount,
                    'product_id' => isset($order['it_id']) ? (string)$order['it_id'] : '',
                    'plan_type' => isset($order['plan_type']) ? (string)$order['plan_type'] : '',
                );
                throw new Exception('준비된 주문 유효시간이 만료되었습니다.');
            }
        }

        $payload = array();
        $rawPayload = isset($order['payload_json']) ? trim((string)$order['payload_json']) : '';
        if ($rawPayload !== '') {
            $payload = json_decode($rawPayload, true);
            if (!is_array($payload)) {
                $payload = array();
            }
        }

        $payloadSourceApprove = isset($payload['source']) ? (string)$payload['source'] : 'order_snapshot';

        // [FIX 2026-05-08] approve detail_rows 우선 적용
        if ($approveDetailRowsRaw !== '') {
            if (is_array($approveDetailRows) && isset($approveDetailRows['__error'])) {
                $approveFailMeta = array(
                    'code' => (string)$approveDetailRows['__error'],
                    'reason' => 'approve_detail_rows_invalid',
                    'received_amount' => (int)$approvedAmount,
                    'product_id' => isset($order['it_id']) ? (string)$order['it_id'] : '',
                    'plan_type' => isset($order['plan_type']) ? (string)$order['plan_type'] : '',
                );
                throw new Exception('승인 처리 중 오류가 발생했습니다.');
            }
            if (!is_array($approveDetailRows) || count($approveDetailRows) < 1) {
                $approveFailMeta = array(
                    'code' => 'ROW_PARSE_FAIL',
                    'reason' => 'approve_detail_rows_empty',
                    'received_amount' => (int)$approvedAmount,
                    'product_id' => isset($order['it_id']) ? (string)$order['it_id'] : '',
                    'plan_type' => isset($order['plan_type']) ? (string)$order['plan_type'] : '',
                );
                throw new Exception('승인 처리 중 오류가 발생했습니다.');
            }

            $approveIds = array();
            foreach ($approveDetailRows as $rr) {
                if (!is_array($rr)) {
                    continue;
                }
                $pid = isset($rr['product_id']) ? preg_replace('/[^A-Za-z0-9_\-]/', '', (string)$rr['product_id']) : '';
                // fee_ 접두사 rows는 VPN 상품이 아니므로 DB 검증 제외
                if ($pid !== '' && substr($pid, 0, 4) !== 'fee_' && !in_array($pid, $approveIds, true)) {
                    $approveIds[] = $pid;
                }
            }
            $approveItems = svb_fetch_items_by_ids($approveIds);
            $approveItemIdx = array();
            foreach ($approveItems as $itRow) {
                if (!empty($itRow['it_id'])) {
                    $approveItemIdx[(string)$itRow['it_id']] = $itRow;
                }
            }
            $fallbackPlan = isset($order['plan_type']) ? (string)$order['plan_type'] : 'hour';
            $fallbackCurrency = isset($order['currency']) ? (string)$order['currency'] : 'KRW';
            $approvePayload = svb_payload_from_detail_rows($mbId, $approveDetailRows, $approveItemIdx, $fallbackPlan, $fallbackCurrency);
            if (!is_array($approvePayload) || count($approvePayload) < 1) {
                $approveFailMeta = array(
                    'code' => 'ROW_PARSE_FAIL',
                    'reason' => 'approve_detail_rows_payload_empty',
                    'received_amount' => (int)$approvedAmount,
                    'product_id' => isset($order['it_id']) ? (string)$order['it_id'] : '',
                    'plan_type' => isset($order['plan_type']) ? (string)$order['plan_type'] : '',
                );
                throw new Exception('승인 처리 중 오류가 발생했습니다.');
            }
            $approvePayload['source'] = 'approve_detail_rows';
            $approvePayload['detail_rows_raw'] = (string)$approveDetailRowsRaw;
            $approvePayload['order_id'] = isset($order['order_id']) ? (string)$order['order_id'] : $orderId;
            $approvePayload['approval_token'] = isset($order['approval_token']) ? (string)$order['approval_token'] : $approvalToken;
            $payload = $approvePayload;
            $payloadSourceApprove = 'approve_detail_rows';
        }

        $expectedAmount = 0;
        if (isset($payload['total_amount'])) {
            $expectedAmount = (int)$payload['total_amount'];
        }
        if ($expectedAmount <= 0) {
            $expectedAmount = (int)$order['total_amount'];
        }
        if ($expectedAmount <= 0) {
            $expectedAmount = (int)$order['point_price'];
        }
        if ($expectedAmount <= 0) {
            svb_log('approve', array(
                'phase' => 'fail', 'reason' => 'snapshot_amount_zero',
                'order_id' => $orderId, 'mb_id_masked' => svb_mask_mb($mbId),
                'source' => $payloadSourceApprove,
            ));
            $approveFailMeta = array(
                'code' => 'AMOUNT_INVALID',
                'reason' => 'snapshot_amount_zero',
                'expected_amount' => 0,
                'received_amount' => (int)$approvedAmount,
                'product_id' => isset($order['it_id']) ? (string)$order['it_id'] : '',
                'plan_type' => isset($order['plan_type']) ? (string)$order['plan_type'] : '',
            );
            throw new Exception('주문 금액 검증에 실패했습니다.');
        }

        if ($approvedAmount > 0 && $approvedAmount !== $expectedAmount) {
            svb_log('approve', array(
                'phase' => 'fail', 'reason' => 'amount_mismatch',
                'order_id' => $orderId, 'mb_id_masked' => svb_mask_mb($mbId),
                'approved_amount' => $approvedAmount,
                'expected_amount' => $expectedAmount,
                'source' => $payloadSourceApprove,
            ));
            $approveFailMeta = array(
                'code' => 'AMOUNT_MISMATCH',
                'reason' => 'amount_mismatch',
                'expected_amount' => (int)$expectedAmount,
                'received_amount' => (int)$approvedAmount,
                'product_id' => isset($order['it_id']) ? (string)$order['it_id'] : '',
                'plan_type' => isset($order['plan_type']) ? (string)$order['plan_type'] : '',
            );
            throw new Exception('주문 금액 검증에 실패했습니다.');
        }

        if ($pgTxId !== '') {
            $safeTx = svb_sql_escape($pgTxId);
            $dupTx = sql_fetch("SELECT order_id FROM vpn_native_buy_orders
                                 WHERE pg_tx_id='{$safeTx}'
                                   AND order_id<>'{$safeOrderId}'
                                 LIMIT 1 FOR UPDATE", false);
            if (!empty($dupTx['order_id'])) {
                throw new Exception('이미 승인된 주문입니다.');
            }
        }

        if ($idempotencyKey !== '') {
            $safeIdem = svb_sql_escape($idempotencyKey);
            $dupIdem = sql_fetch("SELECT order_id FROM vpn_native_buy_orders
                                   WHERE idempotency_key='{$safeIdem}'
                                     AND order_id<>'{$safeOrderId}'
                                   LIMIT 1 FOR UPDATE", false);
            if (!empty($dupIdem['order_id'])) {
                throw new Exception('이미 승인된 주문입니다.');
            }
        }

        $lineCount = 1;
        if (isset($payload['lines']) && is_array($payload['lines']) && count($payload['lines']) > 0) {
            $lineCount = count($payload['lines']);
        }

        // [FIX 2026-05-08] 동시 승인 경쟁상태 방지: 사용자+상품 기준 named lock 확보
        $lockProducts = array();
        if (isset($payload['lines']) && is_array($payload['lines'])) {
            foreach ($payload['lines'] as $ln) {
                $pid = isset($ln['product_id']) ? trim((string)$ln['product_id']) : '';
                if ($pid !== '' && !in_array($pid, $lockProducts, true)) {
                    $lockProducts[] = $pid;
                }
            }
        }
        if (count($lockProducts) < 1 && isset($payload['product_id'])) {
            $pidOne = trim((string)$payload['product_id']);
            if ($pidOne !== '') {
                $lockProducts[] = $pidOne;
            }
        }
        sort($lockProducts);
        foreach ($lockProducts as $pidLock) {
            $lockName = svb_lock_name_for_product($mbId, $pidLock);
            if (!svb_acquire_named_lock($lockName, 10)) {
                $approveFailMeta = array(
                    'code' => 'LOCK_TIMEOUT',
                    'reason' => 'expiry_lock_timeout',
                    'product_id' => (string)$pidLock,
                    'received_amount' => (int)$approvedAmount,
                );
                throw new Exception('승인 처리 중 오류가 발생했습니다.');
            }
            $acquiredLocks[] = $lockName;
        }

        // 실패 진단용 expected expiry(before/after) 계산 (첫 라인 기준)
        if (isset($payload['lines']) && is_array($payload['lines']) && count($payload['lines']) > 0) {
            $firstDiagRow = $payload['lines'][0];
            $diagPid = isset($firstDiagRow['product_id']) ? (string)$firstDiagRow['product_id'] : '';
            $diagQty = isset($firstDiagRow['qty']) ? max(1, (int)$firstDiagRow['qty']) : 1;
            $diagUnitH = isset($firstDiagRow['unit_hours']) ? max(1, (int)$firstDiagRow['unit_hours']) : 1;
            $diagExtHours = $diagQty * $diagUnitH;
            $expectedExpiryBefore = svb_lookup_profile_expiry_by_item($mbId, $diagPid, true);
            $baseTs = time();
            $beforeTs = ($expectedExpiryBefore !== '') ? strtotime($expectedExpiryBefore) : false;
            if ($beforeTs !== false && $beforeTs > $baseTs) {
                $baseTs = $beforeTs;
            }
            $expectedExpiryAfter = date('Y-m-d H:i:s', $baseTs + ($diagExtHours * 3600));
        }

        if (!function_exists('vpnlink_after_native_vpn_purchase')) {
            throw new Exception('승인 처리 중 오류가 발생했습니다.');
        }

        $memberLocked = sql_fetch("SELECT mb_point FROM {$g5['member_table']} WHERE mb_id='{$safeMb}' LIMIT 1 FOR UPDATE", false);
        $pointBefore = isset($memberLocked['mb_point']) ? (int)$memberLocked['mb_point'] : 0;
        if ($pointBefore < $expectedAmount) {
            $approveFailMeta = array(
                'code' => 'POINT_LACK',
                'reason' => 'insufficient_points',
                'expected_amount' => (int)$expectedAmount,
                'received_amount' => (int)$approvedAmount,
                'product_id' => isset($order['it_id']) ? (string)$order['it_id'] : '',
                'plan_type' => isset($order['plan_type']) ? (string)$order['plan_type'] : '',
            );
            throw new Exception('포인트가 부족합니다.');
        }

        $orderForHook = $order;
        $orderForHook['point_price'] = $expectedAmount;
        $orderForHook['total_amount'] = $expectedAmount;
        $orderForHook['payload_json'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

        $hookResult = vpnlink_after_native_vpn_purchase($orderForHook, $member);
        $hookOk = false;
        if (is_array($hookResult)) {
            if (isset($hookResult['success']) && $hookResult['success']) {
                $hookOk = true;
            }
            if (isset($hookResult['result']) && (string)$hookResult['result'] === 'success') {
                $hookOk = true;
            }
        }
        if (!$hookOk) {
            $approveFailMeta = array(
                'code' => 'HOOK_FAILED',
                'reason' => 'purchase_hook_failed',
                'expected_amount' => (int)$expectedAmount,
                'received_amount' => (int)$approvedAmount,
                'product_id' => isset($firstDiagRow['product_id']) ? (string)$firstDiagRow['product_id'] : (isset($order['it_id']) ? (string)$order['it_id'] : ''),
                'qty' => isset($firstDiagRow['qty']) ? (int)$firstDiagRow['qty'] : 0,
                'unit_hours' => isset($firstDiagRow['unit_hours']) ? (int)$firstDiagRow['unit_hours'] : 0,
                'expected_expiry_before' => (string)$expectedExpiryBefore,
                'expected_expiry_after' => (string)$expectedExpiryAfter,
            );
            svb_log('approve', array(
                'phase' => 'fail', 'reason' => 'hook_failed',
                'order_id' => $orderId, 'mb_id_masked' => svb_mask_mb($mbId),
                'hook_message' => is_array($hookResult) && isset($hookResult['message']) ? (string)$hookResult['message'] : '',
            ));
            throw new Exception('승인 처리 중 오류가 발생했습니다.');
        }

        $safeTxSql = $pgTxId !== '' ? "'" . svb_sql_escape($pgTxId) . "'" : 'NULL';
        $safeIdemSql = $idempotencyKey !== '' ? "'" . svb_sql_escape($idempotencyKey) . "'" : 'NULL';
                $safePayloadSql = "'" . svb_sql_escape(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . "'";
        $approvedAmountFinal = $approvedAmount > 0 ? $approvedAmount : $expectedAmount;

        $up = "UPDATE vpn_native_buy_orders
                  SET status='APPROVED',
                      approved_at=NOW(),
                      pg_tx_id={$safeTxSql},
                      approved_amount=" . (int)$approvedAmountFinal . ",
                                            idempotency_key={$safeIdemSql},
                                            payload_json={$safePayloadSql}
                WHERE order_id='{$safeOrderId}'
                  AND mb_id='{$safeMb}'
                LIMIT 1";
        if (!sql_query($up, false)) {
            throw new Exception('승인 처리 중 오류가 발생했습니다.');
        }

        sql_query('COMMIT', false);

        // [FIX 2026-05-07] 적용된 만료시각/포인트 차감 결과를 응답에 포함
        $shopOdId = '';
        if (is_array($hookResult)) {
            if (!empty($hookResult['shop_od_id'])) {
                $shopOdId = (string)$hookResult['shop_od_id'];
            }
        }
        $newExpiry = $shopOdId !== '' ? svb_lookup_new_expiry($shopOdId, $mbId) : '';
        $oldExpiry = isset($payload['target_profile_old_expiry']) ? (string)$payload['target_profile_old_expiry'] : '';
        $durationHours = isset($payload['total_hours']) ? (int)$payload['total_hours'] : 0;
        if ($durationHours <= 0) {
            $durationHours = (int)$order['unit_hours'] * max(1, (int)$order['qty']);
        }

        // [FIX 2026-05-08] 라인별 vpnid_list 등록 확인
        //   - 동일 it_id 가 여러 라인이면 한 번만 조회(마지막 결과 공유).
        //   - 한 row 라도 미등록이면 전체 응답을 fail/PARTIAL_SYNC_FAIL 로 명시.
        $itIdsForCheck = array();
        if (isset($payload['lines']) && is_array($payload['lines'])) {
            foreach ($payload['lines'] as $ln) {
                $pid = isset($ln['product_id']) ? (string)$ln['product_id'] : '';
                // fee_ 접두사 rows는 VPN 상품이 아니므로 vpnid_list 확인 제외
                if ($pid !== '' && substr($pid, 0, 4) !== 'fee_' && !in_array($pid, $itIdsForCheck, true)) {
                    $itIdsForCheck[] = $pid;
                }
            }
        }
        $vpnidCheck = svb_verify_vpnid_list_rows($shopOdId, $mbId, $itIdsForCheck);

        // [FIX 2026-05-08] 다건 혼합 기간 응답: 각 row 별 적용값 + vpnid_list 등록여부
        $appliedRows = array();
        $missingRowCount = 0;
        if (isset($payload['lines']) && is_array($payload['lines'])) {
            foreach ($payload['lines'] as $ln) {
                $pid = isset($ln['product_id']) ? (string)$ln['product_id'] : '';
                $qtyL = isset($ln['qty']) ? max(1, (int)$ln['qty']) : 1;
                $unitH = isset($ln['unit_hours']) ? (int)$ln['unit_hours'] : 0;
                $lineH = isset($ln['line_hours']) ? (int)$ln['line_hours'] : ($unitH * $qtyL);
                $lineP = isset($ln['line_amount']) ? (int)$ln['line_amount'] : 0;
                $vinfo = isset($vpnidCheck[$pid]) ? $vpnidCheck[$pid] : array('inserted' => false, 'vpn_no' => 0);
                $inserted = !empty($vinfo['inserted']);
                if (!$inserted) {
                    $missingRowCount++;
                }
                $appliedRows[] = array(
                    'product_id' => $pid,
                    'qty' => $qtyL,
                    'unit_hours' => $unitH,
                    'applied_hours' => $lineH,
                    'applied_price' => $lineP,
                    'vpnid_list_inserted' => $inserted,
                    'vpn_no' => isset($vinfo['vpn_no']) ? (int)$vinfo['vpn_no'] : 0,
                    'profile_id_or_vpn_no' => isset($vinfo['vpn_no']) ? (string)(int)$vinfo['vpn_no'] : '',
                );
            }
        }

        $logCtx = array(
            'phase' => 'response',
            'order_id' => $orderId,
            'mb_id_masked' => svb_mask_mb($mbId),
            'shop_od_id' => $shopOdId,
            'deducted_points' => (int)$expectedAmount,
            'duration_applied_hours' => (int)$durationHours,
            'old_expiry' => $oldExpiry,
            'new_expiry' => $newExpiry,
            'source' => $payloadSourceApprove,
            'applied_rows' => $appliedRows,
            'missing_vpnid_rows' => $missingRowCount,
        );
        svb_log('approve', $logCtx);

        if ($missingRowCount > 0) {
            // [FIX 2026-05-08]
            // 승인 성공 후라도 핵심 동기화 결과가 불일치하면 즉시 보상 롤백을 수행한다.
            // 목표: 포인트/주문 관련 데이터를 주문 이전 상태로 최대한 복원.
            $comp = array('ok' => false, 'message' => 'compensator_not_found');
            if (function_exists('vpnlink_native_purchase_compensate_order')) {
                $comp = vpnlink_native_purchase_compensate_order($orderId, $shopOdId, $mbId, 'missing_vpnid_rows');
            }

            // native order 상태를 롤백 표식으로 남긴다.
            $safeOrderIdRb = svb_sql_escape($orderId);
            $safeMbRb = svb_sql_escape($mbId);
            sql_query("UPDATE vpn_native_buy_orders
                          SET status='ROLLED_BACK',
                              approved_at=NULL,
                              approved_amount=NULL,
                              pg_tx_id=NULL,
                              idempotency_key=NULL
                        WHERE order_id='{$safeOrderIdRb}'
                          AND mb_id='{$safeMbRb}'
                        LIMIT 1", false);

            svb_fail('approve', 'CONSISTENCY_ROLLBACK',
                '주문 데이터 불일치가 감지되어 자동 롤백되었습니다.', array(
                'order_id' => $orderId,
                'shop_od_id' => $shopOdId,
                'approved' => false,
                'rolled_back' => !empty($comp['ok']),
                'idempotent' => false,
                'deducted_points' => 0,
                'duration_applied_hours' => 0,
                'duration_applied_days' => 0,
                'old_expiry' => $oldExpiry,
                'new_expiry' => $newExpiry,
                'provisioned_count' => (int)$lineCount,
                'applied_rows' => $appliedRows,
                'missing_vpnid_rows' => $missingRowCount,
                'compensation' => $comp,
            ));
        }

        svb_success('approve', 'order approved', array(
            'order_id' => $orderId,
            'shop_od_id' => $shopOdId,
            'approved' => true,
            'idempotent' => false,
            'deducted_points' => (int)$expectedAmount,
            'duration_applied_hours' => (int)$durationHours,
            'duration_applied_days' => (float)round($durationHours / 24, 6),
            'old_expiry' => $oldExpiry,
            'new_expiry' => $newExpiry,
            'provisioned_count' => (int)$lineCount,
            'applied_rows' => $appliedRows,
        ));
    } catch (Exception $e) {
        sql_query('ROLLBACK', false);
        $failCode = 'APPROVE_FAILED';
        $failReason = 'exception';
        $failExtra = array();
        if (is_array($approveFailMeta) && !empty($approveFailMeta)) {
            if (!empty($approveFailMeta['code'])) {
                $failCode = (string)$approveFailMeta['code'];
            }
            if (!empty($approveFailMeta['reason'])) {
                $failReason = (string)$approveFailMeta['reason'];
            }
            $failExtra = $approveFailMeta;
            unset($failExtra['code']);
            unset($failExtra['reason']);
        }
        svb_log('approve', array(
            'phase' => 'fail', 'reason' => $failReason,
            'fail_code' => $failCode,
            'order_id' => $orderId,
            'mb_id_masked' => svb_mask_mb($mbId),
            'message' => $e->getMessage(),
        ));
        $failExtra['fail_reason'] = $failReason;
        $failExtra['order_id'] = $orderId;
        $failExtra['received_amount'] = isset($failExtra['received_amount']) ? (int)$failExtra['received_amount'] : (int)$approvedAmount;
        if (!isset($failExtra['product_id']) || (string)$failExtra['product_id'] === '') {
            $failExtra['product_id'] = isset($firstDiagRow['product_id']) ? (string)$firstDiagRow['product_id'] : '';
        }
        if (!isset($failExtra['qty']) && isset($firstDiagRow['qty'])) {
            $failExtra['qty'] = (int)$firstDiagRow['qty'];
        }
        if (!isset($failExtra['unit_hours']) && isset($firstDiagRow['unit_hours'])) {
            $failExtra['unit_hours'] = (int)$firstDiagRow['unit_hours'];
        }
        if (!isset($failExtra['expected_expiry_before'])) {
            $failExtra['expected_expiry_before'] = (string)$expectedExpiryBefore;
        }
        if (!isset($failExtra['expected_expiry_after'])) {
            $failExtra['expected_expiry_after'] = (string)$expectedExpiryAfter;
        }
        svb_fail('approve', $failCode, svb_public_error_message($e->getMessage()), $failExtra);
    } finally {
        if (is_array($acquiredLocks) && count($acquiredLocks) > 0) {
            foreach ($acquiredLocks as $ln) {
                svb_release_named_lock($ln);
            }
        }
    }
}

// [FIX 2026-05-07] 앱 프로필목록 → 'VPN구매' 진입 시 호출되는 목록 조회 액션
if ($action === 'list_types') {
    $caId = svb_normalize_ca_id(svb_req('ca_id', SVB_DEFAULT_CA_ID));
    $where = svb_build_item_where($caId);

    $row = sql_fetch("SELECT COUNT(*) AS cnt FROM {$g5['g5_shop_item_table']} WHERE {$where}", false);
    $totalCnt = isset($row['cnt']) ? (int)$row['cnt'] : 0;

    // 운영 데이터의 모든 VPN 상품은 시간(hour) 단위 청구로 통일됨.
    // 향후 plan 분류 컬럼이 도입되면 여기에 카운트 분기 추가.
    $types = array(
        array('id' => 'all', 'name' => '전체', 'count' => $totalCnt),
        array('id' => 'hour', 'name' => '시간권', 'count' => $totalCnt),
    );

    $elapsedMs = (int)round((microtime(true) - $svbStartedAt) * 1000);
    svb_log('list_types', array(
        'phase' => 'response',
        'mb_id_masked' => svb_mask_mb($mbId),
        'ca_id' => $caId,
        'total_count' => $totalCnt,
        'types_count' => count($types),
        'elapsed_ms' => $elapsedMs,
    ));

    if ($totalCnt < 1) {
        svb_fail('list_types', 'EMPTY_PRODUCTS', '해당 카테고리에 판매 중인 상품이 없습니다.', array(
            'types' => $types,
            'ca_id' => $caId,
        ));
    }

    svb_success('list_types', 'ok', array(
        'types' => $types,
        'ca_id' => $caId,
    ));
}

if ($action === 'list_grades') {
    $caId = svb_normalize_ca_id(svb_req('ca_id', SVB_DEFAULT_CA_ID));
    $where = svb_build_item_where($caId);
    $itemTable = $g5['g5_shop_item_table'];

    $gradeOrder = array('BASIC', 'PLUS', 'PRO', 'ULTRA', 'MAX');
    $gradeCounts = array_fill_keys($gradeOrder, 0);
    $totalCnt = 0;

    $res = sql_query("SELECT it_spec_grade, COUNT(*) AS cnt FROM {$itemTable} WHERE {$where} GROUP BY it_spec_grade", false);
    if ($res) {
        while ($row = sql_fetch_array($res)) {
            $g = strtoupper(trim((string)$row['it_spec_grade']));
            if (isset($gradeCounts[$g])) {
                $gradeCounts[$g] = (int)$row['cnt'];
            }
            $totalCnt += (int)$row['cnt'];
        }
    }

    $grades = array(
        array('id' => 'all', 'name' => '등급 전체', 'count' => $totalCnt),
    );
    foreach ($gradeOrder as $g) {
        $grades[] = array('id' => strtolower($g), 'name' => $g, 'count' => $gradeCounts[$g]);
    }

    svb_success('list_grades', 'ok', array(
        'grades' => $grades,
        'ca_id' => $caId,
    ));
}

if ($action === 'list_products') {
    $caId = svb_normalize_ca_id(svb_req('ca_id', SVB_DEFAULT_CA_ID));

    // plan/plan_type 혼용 허용 → 내부 표준화 (현 데이터에선 결과 필터링 영향 없음, 메타로만 사용)
    $planRaw = svb_req('plan_type', svb_req('plan', 'all'));
    $planNorm = strtolower(trim((string)$planRaw));
    if ($planNorm === '' || $planNorm === 'all' || $planNorm === '0') {
        $planNorm = 'all';
    } else {
        $planNorm = svb_normalize_plan($planNorm);
    }

    $page = max(1, svb_to_int(svb_req('page', '1'), 1));
    $perPage = svb_to_int(svb_req('per_page', '50'), 50);
    if ($perPage < 1) {
        $perPage = 50;
    }
    if ($perPage > 200) {
        $perPage = 200;
    }
    $offset = ($page - 1) * $perPage;

    list($sort, $sortodr) = svb_normalize_sort(svb_req('sort', 'it_price'), svb_req('sortodr', 'asc'));

    $gradeRaw = svb_req('grade', 'all');
    $gradeNorm = strtoupper(trim((string)$gradeRaw));
    $allowedGrades = array('BASIC', 'PLUS', 'PRO', 'ULTRA', 'MAX');
    if ($gradeNorm === '' || $gradeNorm === 'ALL' || !in_array($gradeNorm, $allowedGrades)) {
        $gradeNorm = 'all';
    }

    $where = svb_build_item_where($caId);
    if ($gradeNorm !== 'all') {
        $where .= " AND it_spec_grade = '" . svb_sql_escape($gradeNorm) . "'";
    }

    $itemTable = $g5['g5_shop_item_table'];
    $itemCols = function_exists('vpnlink_native_purchase_table_columns')
        ? vpnlink_native_purchase_table_columns($itemTable)
        : array();

    $optionCols = array('it_soldout', 'it_buy_min_qty', 'it_buy_max_qty', 'it_basic', 'it_explan', 'it_img1', 'ca_id', 'ca_id2', 'ca_id3', 'it_billing_plan', 'it_is_hour_sale_price', 'it_spec_remote_ip_country_code', 'it_spec_grade', 'it_install_price', 'it_10');
    $selectCols = array('it_id', 'it_name', 'it_price', 'it_use');
    foreach ($optionCols as $opt) {
        if (count($itemCols) < 1 || isset($itemCols[$opt])) {
            $selectCols[] = $opt;
        }
    }
    if (count($itemCols) < 1 || isset($itemCols['pt_id'])) {
        $selectCols[] = 'pt_id';
    }
    $selectSql = implode(', ', array_unique($selectCols));

    $cntRow = sql_fetch("SELECT COUNT(*) AS cnt FROM {$itemTable} WHERE {$where}", false);
    $totalCnt = isset($cntRow['cnt']) ? (int)$cntRow['cnt'] : 0;

    $sql = "SELECT {$selectSql}
              FROM {$itemTable}
             WHERE {$where}
          ORDER BY {$sort} {$sortodr}
             LIMIT {$offset}, {$perPage}";
    $res = sql_query($sql, false);

    $rawCount = 0;
    $products = array();
    if ($res) {
        while ($row = sql_fetch_array($res)) {
            $rawCount++;
            $itId = isset($row['it_id']) ? (string)$row['it_id'] : '';
            if ($itId === '') {
                continue;
            }
            $products[] = array(
                'it_id' => $itId,
                'it_name' => isset($row['it_name']) && trim((string)$row['it_name']) !== ''
                    ? (string)$row['it_name']
                    : ('VPN-' . $itId),
                'it_price' => isset($row['it_price']) ? (string)(int)$row['it_price'] : '0',
                'it_soldout' => isset($row['it_soldout']) ? (string)(int)$row['it_soldout'] : '0',
                'it_buy_min_qty' => isset($row['it_buy_min_qty']) ? (string)(int)$row['it_buy_min_qty'] : '1',
                'it_buy_max_qty' => isset($row['it_buy_max_qty']) ? (string)(int)$row['it_buy_max_qty'] : '999',
                'it_billing_plan' => (isset($row['it_billing_plan']) && trim((string)$row['it_billing_plan']) !== '') ? trim((string)$row['it_billing_plan']) : 'hour',
                'it_is_hour_sale_price' => isset($row['it_is_hour_sale_price']) ? (string)(int)$row['it_is_hour_sale_price'] : '0',
                'country_code' => isset($row['it_spec_remote_ip_country_code']) ? trim((string)$row['it_spec_remote_ip_country_code']) : '',
                'ca_id' => isset($row['ca_id']) ? (string)$row['ca_id'] : $caId,
                'seller_id' => isset($row['pt_id']) ? trim((string)$row['pt_id']) : '',
                'provider_name' => '',
                'grade' => (isset($row['it_spec_grade']) && trim((string)$row['it_spec_grade']) !== '') ? strtoupper(trim((string)$row['it_spec_grade'])) : '',
                'install_price' => (isset($row['it_install_price']) && (int)$row['it_install_price'] > 0) ? (int)$row['it_install_price'] : 0,
                'install_free_hours' => (isset($row['it_10']) && (int)$row['it_10'] > 0) ? (int)$row['it_10'] : 0,
            );
        }
    }

    // 판매자 닉네임 배치 조회
    $sellerNickMap = array();
    $allPtIds = array();
    foreach ($products as $p) {
        if (!empty($p['seller_id'])) {
            $allPtIds[] = $p['seller_id'];
        }
    }
    if (!empty($allPtIds)) {
        $memberTable = isset($g5['member_table']) ? $g5['member_table'] : 'g5_member';
        $inClause = implode(',', array_map(function($id) { return "'" . svb_sql_escape($id) . "'"; }, array_unique($allPtIds)));
        $nickRes = sql_query("SELECT mb_id, mb_nick FROM {$memberTable} WHERE mb_id IN ({$inClause})", false);
        if ($nickRes) {
            while ($nr = sql_fetch_array($nickRes)) {
                if (!empty($nr['mb_id'])) {
                    $sellerNickMap[(string)$nr['mb_id']] = trim((string)$nr['mb_nick']);
                }
            }
        }
    }
    foreach ($products as &$p) {
        $ptId = isset($p['seller_id']) ? $p['seller_id'] : '';
        $p['provider_name'] = isset($sellerNickMap[$ptId]) ? $sellerNickMap[$ptId] : '';
    }
    unset($p);

    $totalPages = $perPage > 0 ? (int)ceil($totalCnt / $perPage) : 1;
    if ($totalPages < 1) {
        $totalPages = 1;
    }
    $elapsedMs = (int)round((microtime(true) - $svbStartedAt) * 1000);

    svb_log('list_products', array(
        'phase' => 'response',
        'mb_id_masked' => svb_mask_mb($mbId),
        'ca_id' => $caId,
        'plan' => $planNorm,
        'page' => $page,
        'per_page' => $perPage,
        'sort' => $sort,
        'sortodr' => $sortodr,
        'raw_rows' => $rawCount,
        'returned_rows' => count($products),
        'total_count' => $totalCnt,
        'elapsed_ms' => $elapsedMs,
    ));

    if (count($products) < 1) {
        $reason = $totalCnt > 0 ? 'pagination_out_of_range' : 'no_active_items_in_category';
        svb_fail('list_products', 'EMPTY_PRODUCTS', '해당 조건에 맞는 상품이 없습니다.', array(
            'products' => array(),
            'page' => $page,
            'total_pages' => $totalPages,
            'total_count' => $totalCnt,
            'ca_id' => $caId,
            'plan' => $planNorm,
            'empty_reason' => $reason,
        ));
    }

    svb_success('list_products', 'ok', array(
        'products' => $products,
        'page' => $page,
        'total_pages' => $totalPages,
        'total_count' => $totalCnt,
        'ca_id' => $caId,
        'plan' => $planNorm,
    ));
}

svb_log($action, array('phase' => 'invalid_action'));
svb_fail($action, 'INVALID_ACTION', '잘못된 요청입니다.');
