<?php
/**
 * g5check — 그누보드·영카트 PHP 8 전환 점검기 (소스 검사기)
 *
 * 그누보드5/영카트5 폴더를 읽어서, PHP 8.x 로 올렸을 때 깨지거나 경고가 나는 코드를
 * 파일·줄 번호와 함께 알려준다. 파일을 고치거나 밖으로 보내지 않는다(읽기 전용).
 *
 *   php g5check.php <그누보드 폴더> [--target=8.3] [--format=text|md|json] [--out=파일] [--all]
 *
 *   --target   올릴 PHP 버전 (기본 8.3). 8.0~8.4
 *   --format   출력 형식 (기본 text)
 *   --out      결과를 파일로 저장
 *   --all      data/ 같은 기본 제외 폴더까지 검사
 *   --verbose  참고(INFO) 항목까지 자세히 출력
 *
 * 이 파일은 낡은 서버(PHP 5.6~7.4)에서도 돌아가도록 PHP 5.6 문법으로만 쓴다.
 * 웹에서 열리면 아무것도 하지 않는다(명령줄 전용).
 */

if (PHP_SAPI !== 'cli') {
    header('HTTP/1.1 403 Forbidden');
    exit("g5check 는 명령줄(CLI)에서만 실행됩니다.\n");
}

define('G5CHECK_VER', '0.1.0');

/* ───────────────────────── 규칙 ───────────────────────── */

// 등급: FATAL = PHP 8 에서 바로 오류(화면이 안 뜸) / ERROR = 실행 중 오류 가능성 높음 / WARN = 경고·폐지 예정(동작은 함)
//       INFO  = 문제 코드지만 function_exists·PHP_VERSION 검사나 mysqli 대체 분기로 막혀 있어 실제로는 실행되지 않을 가능성이 큼
// since: 그 버전부터 문제
function g5c_removed_functions()
{
    // 함수명 => array(등급, 문제가 되는 버전, 설명, 바꿀 방법)
    return array(
        'each'                   => array('FATAL', '8.0', 'each() 가 삭제되었습니다.', 'foreach 문으로 바꾸세요.'),
        'create_function'        => array('FATAL', '8.0', 'create_function() 이 삭제되었습니다.', '익명 함수 function () { ... } 로 바꾸세요.'),
        'get_magic_quotes_gpc'   => array('FATAL', '8.0', 'get_magic_quotes_gpc() 가 삭제되었습니다.', '항상 false 를 돌려주던 함수라 조건문째 지우거나 false 로 바꾸세요.'),
        'get_magic_quotes_runtime' => array('FATAL', '8.0', 'get_magic_quotes_runtime() 이 삭제되었습니다.', 'false 로 바꾸세요.'),
        'set_magic_quotes_runtime' => array('FATAL', '7.0', 'set_magic_quotes_runtime() 이 삭제되었습니다.', '호출을 지우세요.'),
        'money_format'           => array('FATAL', '8.0', 'money_format() 이 삭제되었습니다.', 'number_format() 이나 NumberFormatter 로 바꾸세요.'),
        'is_real'                => array('FATAL', '8.0', 'is_real() 이 삭제되었습니다.', 'is_float() 로 바꾸세요.'),
        'fgetss'                 => array('FATAL', '8.0', 'fgetss() 가 삭제되었습니다.', 'strip_tags(fgets(...)) 로 바꾸세요.'),
        'hebrevc'                => array('FATAL', '8.0', 'hebrevc() 가 삭제되었습니다.', 'nl2br(hebrev(...)) 로 바꾸세요.'),
        'convert_cyr_string'     => array('FATAL', '8.0', 'convert_cyr_string() 이 삭제되었습니다.', 'mb_convert_encoding() 으로 바꾸세요.'),
        'restore_include_path'   => array('FATAL', '8.0', 'restore_include_path() 가 삭제되었습니다.', "ini_restore('include_path') 로 바꾸세요."),
        'ezmlm_hash'             => array('FATAL', '8.0', 'ezmlm_hash() 가 삭제되었습니다.', '호출을 지우세요.'),
        'image2wbmp'             => array('FATAL', '8.0', 'image2wbmp() 가 삭제되었습니다.', 'imagewbmp() 로 바꾸세요.'),
        'jpeg2wbmp'              => array('FATAL', '8.0', 'jpeg2wbmp() 가 삭제되었습니다.', 'imagecreatefromjpeg() + imagewbmp() 로 바꾸세요.'),
        'png2wbmp'               => array('FATAL', '8.0', 'png2wbmp() 가 삭제되었습니다.', 'imagecreatefrompng() + imagewbmp() 로 바꾸세요.'),
        'read_exif_data'         => array('FATAL', '8.0', 'read_exif_data() 가 삭제되었습니다.', 'exif_read_data() 로 바꾸세요.'),
        'gmp_random'             => array('FATAL', '8.0', 'gmp_random() 이 삭제되었습니다.', 'gmp_random_bits() 로 바꾸세요.'),
        'ldap_sort'              => array('FATAL', '8.0', 'ldap_sort() 가 삭제되었습니다.', '결과를 PHP 에서 정렬하세요.'),
        'mbregex_encoding'       => array('FATAL', '8.0', 'mbregex_encoding() 이 삭제되었습니다.', 'mb_regex_encoding() 으로 바꾸세요.'),
        'mbereg'                 => array('FATAL', '8.0', 'mbereg() 별칭이 삭제되었습니다.', 'mb_ereg() 로 바꾸세요.'),
        'mberegi'                => array('FATAL', '8.0', 'mberegi() 별칭이 삭제되었습니다.', 'mb_eregi() 로 바꾸세요.'),
        'mbereg_replace'         => array('FATAL', '8.0', 'mbereg_replace() 별칭이 삭제되었습니다.', 'mb_ereg_replace() 로 바꾸세요.'),
        'mbsplit'                => array('FATAL', '8.0', 'mbsplit() 별칭이 삭제되었습니다.', 'mb_split() 으로 바꾸세요.'),
        'ereg'                   => array('FATAL', '7.0', 'ereg() 가 삭제되었습니다.', 'preg_match() 로 바꾸세요.'),
        'eregi'                  => array('FATAL', '7.0', 'eregi() 가 삭제되었습니다.', 'preg_match() 에 /i 를 붙여 바꾸세요.'),
        'ereg_replace'           => array('FATAL', '7.0', 'ereg_replace() 가 삭제되었습니다.', 'preg_replace() 로 바꾸세요.'),
        'eregi_replace'          => array('FATAL', '7.0', 'eregi_replace() 가 삭제되었습니다.', 'preg_replace() 에 /i 를 붙여 바꾸세요.'),
        'split'                  => array('FATAL', '7.0', 'split() 이 삭제되었습니다.', 'explode() 나 preg_split() 으로 바꾸세요.'),
        'spliti'                 => array('FATAL', '7.0', 'spliti() 가 삭제되었습니다.', 'preg_split() 에 /i 를 붙여 바꾸세요.'),
        'sql_regcase'            => array('FATAL', '7.0', 'sql_regcase() 가 삭제되었습니다.', '직접 정규식을 만드세요.'),
        'call_user_method'       => array('FATAL', '7.0', 'call_user_method() 가 삭제되었습니다.', 'call_user_func() 로 바꾸세요.'),
        'call_user_method_array' => array('FATAL', '7.0', 'call_user_method_array() 가 삭제되었습니다.', 'call_user_func_array() 로 바꾸세요.'),
        'session_register'       => array('FATAL', '5.4', 'session_register() 가 삭제되었습니다.', '$_SESSION 을 직접 쓰세요.'),
        'session_unregister'     => array('FATAL', '5.4', 'session_unregister() 가 삭제되었습니다.', 'unset($_SESSION[...]) 로 바꾸세요.'),
        'session_is_registered'  => array('FATAL', '5.4', 'session_is_registered() 가 삭제되었습니다.', 'isset($_SESSION[...]) 로 바꾸세요.'),
        '__autoload'             => array('FATAL', '8.0', '__autoload() 가 삭제되었습니다.', 'spl_autoload_register() 로 바꾸세요.'),
        // 폐지 예정(경고만 나옴)
        'utf8_encode'            => array('WARN', '8.2', 'utf8_encode() 는 폐지 예정입니다.', "mb_convert_encoding(\$s, 'UTF-8', 'ISO-8859-1') 로 바꾸세요."),
        'utf8_decode'            => array('WARN', '8.2', 'utf8_decode() 는 폐지 예정입니다.', "mb_convert_encoding(\$s, 'ISO-8859-1', 'UTF-8') 로 바꾸세요."),
        'strftime'               => array('WARN', '8.1', 'strftime() 은 폐지 예정입니다.', 'date() 나 IntlDateFormatter 로 바꾸세요.'),
        'gmstrftime'             => array('WARN', '8.1', 'gmstrftime() 은 폐지 예정입니다.', 'gmdate() 로 바꾸세요.'),
        'date_sunrise'           => array('WARN', '8.1', 'date_sunrise() 는 폐지 예정입니다.', 'date_sun_info() 로 바꾸세요.'),
        'date_sunset'            => array('WARN', '8.1', 'date_sunset() 은 폐지 예정입니다.', 'date_sun_info() 로 바꾸세요.'),
        'mhash'                  => array('WARN', '8.1', 'mhash() 는 폐지 예정입니다.', 'hash() 로 바꾸세요.'),
        'mysqli_ping'            => array('WARN', '8.4', 'mysqli_ping() 은 폐지 예정입니다.', '호출을 지우거나 재연결 로직으로 바꾸세요.'),
        'mysqli_kill'            => array('WARN', '8.4', 'mysqli_kill() 은 폐지 예정입니다.', "KILL 쿼리를 직접 실행하세요."),
    );
}

// mysql_* 은 여러 개라 접두어로 잡는다
function g5c_is_mysql_func($name) { return strpos($name, 'mysql_') === 0; }

function g5c_ver_ge($a, $b) { return version_compare($a, $b, '>='); }

// 그 줄과 바로 위 3줄 안에 function_exists('이름')·PHP_VERSION 비교가 있으면 '보호된 코드'로 본다
function g5c_guarded($lines, $line, $name)
{
    // function_exists('이름') 검사는 위 15줄까지 (없으면 일찍 return 하는 형태가 흔하다), PHP 버전 비교는 위 3줄까지
    for ($k = $line - 1; $k >= max(0, $line - 16); $k--) {
        if (!isset($lines[$k])) continue;
        $l = $lines[$k];
        if (stripos($l, "function_exists('" . $name . "')") !== false || stripos($l, 'function_exists("' . $name . '")') !== false) return true;
        if ($k >= $line - 4 && preg_match('/PHP_VERSION|PHP_MAJOR_VERSION|version_compare\s*\(\s*phpversion/i', $l)) return true;
    }
    return false;
}

/* ───────────────────────── 파일 모으기 ───────────────────────── */

function g5c_collect($root, $all)
{
    $skip = $all ? array('.git', 'node_modules') : array('.git', 'node_modules', 'data');
    $files = array();
    $it = new RecursiveIteratorIterator(
        new RecursiveCallbackFilterIterator(
            new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
            function ($cur, $key, $iter) use ($skip, $root) {
                if ($iter->hasChildren()) {
                    // 최상위 data/ 만 건너뛴다 (업로드·캐시)
                    $rel = substr($cur->getPathname(), strlen($root) + 1);
                    $first = strtok(str_replace('\\', '/', $rel), '/');
                    return !in_array($cur->getFilename(), array('.git', 'node_modules'), true)
                        && !(in_array($first, $skip, true) && strpos(str_replace('\\', '/', $rel), '/') === false);
                }
                return strtolower(pathinfo($cur->getFilename(), PATHINFO_EXTENSION)) === 'php';
            }
        )
    );
    foreach ($it as $f) $files[] = $f->getPathname();
    sort($files);
    return $files;
}

/* ───────────────────────── 그누보드 버전 ───────────────────────── */

function g5c_detect_version($root)
{
    $out = array('gnuboard' => '', 'youngcart' => '', 'mysqli_report' => false);
    $cfg = $root . DIRECTORY_SEPARATOR . 'config.php';
    if (is_file($cfg)) {
        $s = file_get_contents($cfg);
        if (preg_match("/define\\(\\s*'G5_GNUBOARD_VER'\\s*,\\s*'([^']+)'/", $s, $m)) $out['gnuboard'] = $m[1];
        if (preg_match("/define\\(\\s*'G5_YOUNGCART_VER'\\s*,\\s*'([^']+)'/", $s, $m)) $out['youngcart'] = $m[1];
    }
    $ver = $root . DIRECTORY_SEPARATOR . 'version.php';
    if ($out['gnuboard'] === '' && is_file($ver)) {
        $s = file_get_contents($ver);
        if (preg_match("/G5_GNUBOARD_VER'\\s*,\\s*'([^']+)'/", $s, $m)) $out['gnuboard'] = $m[1];
        if (preg_match("/G5_YOUNGCART_VER'\\s*,\\s*'([^']+)'/", $s, $m)) $out['youngcart'] = $m[1];
    }
    return $out;
}

/* ───────────────────────── 토큰 검사 ───────────────────────── */

// 토큰 하나를 array(종류, 글자, 줄) 로 맞춘다
function g5c_norm($tokens)
{
    $out = array(); $line = 1;
    foreach ($tokens as $t) {
        if (is_array($t)) { $line = $t[2]; $out[] = array($t[0], $t[1], $t[2]); $line += substr_count($t[1], "\n"); }
        else $out[] = array(0, $t, $line);
    }
    return $out;
}

function g5c_is_ws($t) { return in_array($t[0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true); }

function g5c_next($tk, $i) { $n = count($tk); for ($j = $i + 1; $j < $n; $j++) if (!g5c_is_ws($tk[$j])) return $j; return -1; }
function g5c_prev($tk, $i) { for ($j = $i - 1; $j >= 0; $j--) if (!g5c_is_ws($tk[$j])) return $j; return -1; }

// 배열만 받는 내장 함수 (PHP 8 부터 배열이 아닌 값을 넘기면 TypeError)
function g5c_array_funcs()
{
    return array('ksort', 'krsort', 'sort', 'rsort', 'asort', 'arsort', 'usort', 'uasort', 'uksort', 'natsort', 'natcasesort',
        'count', 'sizeof', 'in_array', 'array_keys', 'array_values', 'array_merge', 'array_walk', 'array_map', 'array_filter',
        'array_count_values', 'array_key_exists', 'array_search', 'array_slice', 'array_splice', 'array_unique', 'array_flip',
        'array_reverse', 'array_sum', 'array_shift', 'array_pop', 'array_push', 'array_unshift', 'array_combine', 'array_diff',
        'array_intersect', 'array_fill_keys', 'array_column', 'array_chunk', 'array_pad', 'array_rand', 'shuffle');
}

// $open 위치의 { 와 짝이 되는 } 위치 (없으면 -1)
function g5c_match_brace($tk, $open)
{
    $d = 0; $n = count($tk);
    for ($j = $open; $j < $n; $j++) {
        if ($tk[$j][1] === '{' || $tk[$j][0] === T_CURLY_OPEN || $tk[$j][0] === T_DOLLAR_OPEN_CURLY_BRACES) $d++;
        elseif ($tk[$j][1] === '}') { $d--; if ($d === 0) return $j; }
    }
    return -1;
}

// 함수 호출 인자 개수와 각 인자의 첫 토큰 (괄호 위치 $open 부터)
function g5c_call_args($tk, $open)
{
    $depth = 0; $args = array(); $cur = array(); $n = count($tk);
    for ($j = $open; $j < $n; $j++) {
        $v = $tk[$j][1];
        if ($v === '(' || $v === '[' || $v === '{' || $tk[$j][0] === T_CURLY_OPEN || $tk[$j][0] === T_DOLLAR_OPEN_CURLY_BRACES) {
            $depth++;
            if ($depth === 1) continue;
        } elseif ($v === ')' || $v === ']' || $v === '}') {
            $depth--;
            if ($depth === 0) { if ($cur) $args[] = $cur; return $args; }
        } elseif ($v === ',' && $depth === 1) { $args[] = $cur; $cur = array(); continue; }
        if (!g5c_is_ws($tk[$j])) $cur[] = $tk[$j];
    }
    return $args;
}

function g5c_scan_file($path, $root, $target, &$found, &$parseErr)
{
    $src = @file_get_contents($path);
    if ($src === false || $src === '') return;
    $rel = str_replace('\\', '/', substr($path, strlen($root) + 1));

    $tokens = @token_get_all($src);
    $tk = g5c_norm($tokens);
    $n = count($tk);
    $rules = g5c_removed_functions();
    $add = function ($lv, $since, $line, $msg, $fix, $code) use (&$found, $rel, $target) {
        if (!g5c_ver_ge($target, $since)) return;
        $found[] = array('level' => $lv, 'since' => $since, 'file' => $rel, 'line' => $line, 'msg' => $msg, 'fix' => $fix, 'code' => $code);
    };

    $lines = preg_split('/\r\n|\r|\n/', $src);
    $hasMysqli = stripos($src, 'mysqli_') !== false;
    $codeAt = function ($line) use (&$lines, $src) {
        return isset($lines[$line - 1]) ? trim(function_exists('mb_substr') ? mb_substr($lines[$line - 1], 0, 160, 'UTF-8') : substr($lines[$line - 1], 0, 240)) : '';
    };

    $depthQ = array(); // 괄호 깊이별 삼항 연산 상태
    $depth = 0;
    $curlyEnd = array(); // 중괄호 인덱스의 닫는 } 위치 ($a{'x'}{'y'} 처럼 이어진 것 찾기용)
    for ($i = 0; $i < $n; $i++) {
        $t = $tk[$i];
        $type = $t[0]; $val = $t[1]; $line = $t[2];

        // 1) 삭제된 함수 호출
        if ($type === T_STRING) {
            $lname = strtolower($val);
            $nx = g5c_next($tk, $i);
            $pv = g5c_prev($tk, $i);
            $pvType = $pv >= 0 ? $tk[$pv][0] : null;
            $isCall = $nx >= 0 && $tk[$nx][1] === '(';
            $isMethodOrDecl = in_array($pvType, array(T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST), true)
                || ($pv >= 0 && $tk[$pv][1] === '\\' && false);
            if ($isCall && !$isMethodOrDecl) {
                // @ksort($x) — PHP 7 은 배열이 아니면 경고(@로 숨김), PHP 8 은 TypeError 라 @ 로 못 막는다
                if ($pv >= 0 && $tk[$pv][1] === '@' && in_array($lname, g5c_array_funcs(), true)) {
                    $a0 = g5c_next($tk, $nx);
                    $var = ($a0 >= 0 && $tk[$a0][0] === T_VARIABLE) ? $tk[$a0][1] : '';
                    $chk = false;
                    if ($var !== '') {
                        for ($k = $line - 1; $k >= max(1, $line - 3); $k--) {
                            $l = isset($lines[$k - 1]) ? $lines[$k - 1] : '';
                            if (preg_match('/(is_array|isset)\s*\(\s*' . preg_quote($var, '/') . '\b/', $l)) $chk = true;
                        }
                    }
                    $lv = $chk ? 'INFO' : 'ERROR';
                    $add($lv, '8.0', $line, '@' . $val . '() — 값이 배열이 아니면 PHP 8 에서는 @ 로도 막을 수 없는 TypeError 가 납니다.' . ($chk ? ' (바로 위에서 is_array/isset 로 확인하고 있음)' : ''),
                        'if (is_array(' . ($var !== '' ? $var : '$값') . ')) ' . $val . '(...) 처럼 먼저 확인하세요.', $codeAt($line));
                }
                if (isset($rules[$lname])) {
                    $r = $rules[$lname];
                    // function_exists('each') 처럼 이름만 문자열로 다루는 경우는 여기 안 걸린다(토큰이 문자열)
                    $lv = ($r[0] === 'FATAL' && g5c_guarded($lines, $line, $lname)) ? 'INFO' : $r[0];
                    $add($lv, $r[1], $line, $r[2] . ($lv === 'INFO' ? ' (function_exists·PHP 버전 검사로 막혀 있어 실행되지 않을 가능성이 큼)' : ''), $r[3], $codeAt($line));
                } elseif (g5c_is_mysql_func($lname)) {
                    // 그누보드는 mysqli 분기 옆에 옛 mysql 분기를 남겨 둔다 → 같은 파일에 mysqli 가 있으면 대체 분기로 본다
                    $lv = ($hasMysqli || g5c_guarded($lines, $line, $lname)) ? 'INFO' : 'FATAL';
                    $add($lv, '7.0', $line, 'mysql_* 확장이 삭제되었습니다. (' . $val . ')' . ($lv === 'INFO' ? ' — 같은 파일에 mysqli 분기가 있어 실행되지 않을 가능성이 큼' : ''), 'mysqli_* 또는 PDO 로 바꾸세요.', $codeAt($line));
                } elseif ($lname === 'implode' || $lname === 'join') {
                    // implode($배열, '구분자') 옛 순서 — PHP 8 에서 TypeError
                    $args = g5c_call_args($tk, $nx);
                    if (count($args) === 2 && $args[1] && count($args[1]) === 1 && $args[1][0][0] === T_CONSTANT_ENCAPSED_STRING
                        && $args[0] && $args[0][0][0] !== T_CONSTANT_ENCAPSED_STRING) {
                        $add('FATAL', '8.0', $line, $val . '() 의 인자 순서(배열, 구분자)가 더 이상 허용되지 않습니다.', $val . '(구분자, 배열) 순서로 바꾸세요.', $codeAt($line));
                    }
                } elseif ($lname === 'parse_str') {
                    $args = g5c_call_args($tk, $nx);
                    if (count($args) === 1) $add('FATAL', '8.0', $line, 'parse_str() 를 인자 1개로 부를 수 없습니다.', 'parse_str($str, $result) 처럼 두 번째 인자를 넣으세요.', $codeAt($line));
                } elseif ($lname === 'define') {
                    $args = g5c_call_args($tk, $nx);
                    if (count($args) === 3) $add('WARN', '8.0', $line, 'define() 의 세 번째 인자(대소문자 무시)는 더 이상 지원되지 않습니다.', '세 번째 인자를 지우세요.', $codeAt($line));
                } elseif (($lname === 'get_class' || $lname === 'get_parent_class') ) {
                    $args = g5c_call_args($tk, $nx);
                    if (count($args) === 0) $add('WARN', '8.3', $line, $val . '() 를 인자 없이 부르는 방식은 폐지 예정입니다.', $val . '($this) 또는 static::class 로 바꾸세요.', $codeAt($line));
                } elseif ($lname === 'mb_convert_encoding') {
                    $args = g5c_call_args($tk, $nx);
                    foreach ($args as $a) foreach ($a as $at) if ($at[0] === T_CONSTANT_ENCAPSED_STRING && preg_match('/HTML-ENTITIES|QPrint|BASE64|UUENCODE/i', $at[1])) {
                        $add('WARN', '8.2', $line, "mb_convert_encoding() 의 '" . trim($at[1], "'\"") . "' 변환은 폐지 예정입니다.", 'htmlspecialchars()/htmlentities() 등으로 바꾸세요.', $codeAt($line));
                    }
                }
            }
            // 함수·클래스 이름으로 match 쓰기 — PHP 8 예약어
            $isMethod = false;
            if ($pvType === T_FUNCTION) { $pp = g5c_prev($tk, $pv); if ($pp >= 0 && in_array($tk[$pp][0], array(T_PUBLIC, T_PRIVATE, T_PROTECTED, T_STATIC, T_ABSTRACT, T_FINAL), true)) $isMethod = true; }
            if ($lname === 'match' && !$isMethod && in_array($pvType, array(T_FUNCTION, T_CLASS), true)) {
                $add('FATAL', '8.0', $line, "'match' 는 PHP 8 예약어라 이름으로 쓸 수 없습니다.", '다른 이름으로 바꾸세요.', $codeAt($line));
            }
            // 폐지 예정 상수
            if ($val === 'FILTER_SANITIZE_STRING' || $val === 'FILTER_SANITIZE_STRIPPED') {
                $add('WARN', '8.1', $line, $val . ' 상수는 폐지 예정입니다.', 'htmlspecialchars() 로 바꾸세요.', $codeAt($line));
            }
            // 함수 선언: __autoload / 기본값 있는 인자 뒤의 필수 인자 / 암시적 nullable
            if ($pvType === T_FUNCTION && $isCall) {
                if ($lname === '__autoload') $add('FATAL', '8.0', $line, '__autoload() 정의는 더 이상 지원되지 않습니다.', 'spl_autoload_register() 로 바꾸세요.', $codeAt($line));
                $params = g5c_call_args($tk, $nx);
                $seenDefault = false;
                foreach ($params as $p) {
                    $hasDefault = false; $hasType = false; $defNull = false; $variadic = false;
                    foreach ($p as $k => $pt) {
                        if ($pt[1] === '=') { $hasDefault = true; if (isset($p[$k + 1]) && strtolower($p[$k + 1][1]) === 'null') $defNull = true; }
                        if ($pt[0] === T_ELLIPSIS) $variadic = true;
                    }
                    if ($p && in_array($p[0][0], array(T_STRING, T_ARRAY, T_CALLABLE, T_NS_SEPARATOR), true) && $p[0][1] !== '?') $hasType = true;
                    if ($hasDefault) $seenDefault = true;
                    elseif ($seenDefault && !$variadic) {
                        $add('WARN', '8.0', $line, '기본값이 있는 인자 뒤에 필수 인자가 옵니다.', '인자 순서를 바꾸거나 앞 인자의 기본값을 지우세요.', $codeAt($line));
                        $seenDefault = false;
                    }
                    if ($hasType && $defNull) {
                        $add('WARN', '8.4', $line, '타입 선언 + 기본값 null 인 인자(암시적 nullable)는 폐지 예정입니다.', '타입 앞에 ? 를 붙이세요. 예) ?Foo $x = null', $codeAt($line));
                    }
                }
            }
        }

        // 2) 문자열 중괄호 인덱스  $str{0}
        if ($val === '{' && $type === 0) {
            $pv = $i - 1; // 공백 없이 바로 붙은 경우만
            // $this->prop{0} 처럼 속성 뒤에 붙은 것도 같은 문법이다
            $isProp = $pv >= 1 && $tk[$pv][0] === T_STRING && in_array($tk[$pv - 1][0], array(T_OBJECT_OPERATOR, T_DOUBLE_COLON), true);
            if ($pv >= 0 && ($tk[$pv][0] === T_VARIABLE || $tk[$pv][1] === ']' || $isProp || isset($curlyEnd[$pv]))) {
                $e = g5c_match_brace($tk, $i);
                if ($e > 0) $curlyEnd[$e] = true;
                $add('FATAL', '8.0', $line, '중괄호로 문자·배열 요소를 읽는 문법($x{0})이 삭제되었습니다.', '대괄호 $x[0] 로 바꾸세요.', $codeAt($line));
            }
        }

        // 2-1) $obj = &new Foo;  (PHP 7.0 에서 삭제, 7.4 에서도 이미 문법 오류)
        if ($type === T_NEW) {
            $pv = g5c_prev($tk, $i);
            if ($pv >= 0 && $tk[$pv][1] === '&') {
                $add('FATAL', '7.0', $line, '= &new 문법이 삭제되었습니다.', '& 를 빼고 = new 로 바꾸세요.', $codeAt($line));
            }
        }

        // 3) (real)/(unset) 형변환
        if ($type === T_DOUBLE_CAST && stripos(str_replace(' ', '', $val), '(real)') === 0) {
            $add('FATAL', '8.0', $line, '(real) 형변환이 삭제되었습니다.', '(float) 로 바꾸세요.', $codeAt($line));
        }
        if ($type === T_UNSET_CAST) {
            $add('FATAL', '8.0', $line, '(unset) 형변환이 삭제되었습니다.', 'null 대입으로 바꾸세요.', $codeAt($line));
        }

        // 4) "${var}" 문자열 보간
        if ($type === T_DOLLAR_OPEN_CURLY_BRACES) {
            $add('WARN', '8.2', $line, '"${변수}" 문자열 보간은 폐지 예정입니다.', '"{$변수}" 로 바꾸세요.', $codeAt($line));
        }

        // 5) 괄호 없는 중첩 삼항  a ? b : c ? d : e
        if ($val === '(' || $val === '[' || $type === T_CURLY_OPEN || $type === T_DOLLAR_OPEN_CURLY_BRACES || ($val === '{' && $type === 0)) { $depth++; $depthQ[$depth] = 0; }
        elseif ($val === ')' || $val === ']' || $val === '}') { unset($depthQ[$depth]); $depth = max(0, $depth - 1); }
        elseif ($val === ';' || $val === ',' || $type === T_DOUBLE_ARROW) { $depthQ[$depth] = 0; }
        elseif (in_array($type, array(T_OPEN_TAG, T_OPEN_TAG_WITH_ECHO, T_CLOSE_TAG, T_INLINE_HTML), true)) { $depthQ = array(); $depth = 0; }
        elseif ($val === '?' && $type === 0) {
            $st = isset($depthQ[$depth]) ? $depthQ[$depth] : 0;
            $nx = g5c_next($tk, $i);
            $short = $nx >= 0 && $tk[$nx][1] === ':'; // ?: 짧은 삼항
            if ($st === 2 && !$short) {
                $add('FATAL', '8.0', $line, '괄호 없이 중첩한 삼항 연산자(a ? b : c ? d : e)는 PHP 8 에서 오류입니다.', '어느 쪽을 먼저 계산할지 괄호로 묶으세요.', $codeAt($line));
                $depthQ[$depth] = 0;
            } else $depthQ[$depth] = $short ? 0 : 1;
        }
        elseif ($val === ':' && $type === 0) {
            if (isset($depthQ[$depth]) && $depthQ[$depth] === 1) $depthQ[$depth] = 2;
        }
    }
}

/* ───────────────────────── 실행 ───────────────────────── */

function g5c_main($argv)
{
    $opt = array('target' => '8.3', 'format' => 'text', 'out' => '', 'all' => false);
    $root = '';
    foreach (array_slice($argv, 1) as $a) {
        if (preg_match('/^--(target|format|out)=(.*)$/', $a, $m)) $opt[$m[1]] = $m[2];
        elseif ($a === '--all') $opt['all'] = true;
        elseif ($a === '--verbose' || $a === '-v') $GLOBALS['g5c_verbose'] = true;
        elseif ($a === '-h' || $a === '--help') { g5c_help(); return 0; }
        elseif ($root === '') $root = $a;
    }
    if ($root === '' || !is_dir($root)) { g5c_help(); return 2; }
    if (!preg_match('/^8\.[0-4]$/', $opt['target'])) { fwrite(STDERR, "--target 은 8.0~8.4 중 하나로 넣어 주세요.\n"); return 2; }
    $root = rtrim(realpath($root), '\\/');

    $files = g5c_collect($root, $opt['all']);
    $found = array(); $parseErr = array();
    foreach ($files as $f) g5c_scan_file($f, $root, $opt['target'], $found, $parseErr);

    $info = g5c_detect_version($root);

    // 프로젝트 단위: PHP 8.1 부터 mysqli 가 오류를 예외로 던진다
    $usesMysqli = false; $hasReport = false;
    foreach ($files as $f) {
        $s = @file_get_contents($f);
        if ($s === false) continue;
        if (!$usesMysqli && stripos($s, 'mysqli_') !== false) $usesMysqli = true;
        if (!$hasReport && preg_match('/mysqli_report\s*\(/i', $s)) $hasReport = true;
        if ($usesMysqli && $hasReport) break;
    }
    if ($usesMysqli && !$hasReport && g5c_ver_ge($opt['target'], '8.1')) {
        $found[] = array('level' => 'FATAL', 'since' => '8.1', 'file' => '(전체)', 'line' => 0,
            'msg' => 'PHP 8.1 부터 mysqli 가 SQL 오류를 예외로 던집니다. 그누보드는 오류를 직접 확인하는 구조라, 쿼리 하나만 실패해도 화면 전체가 멈춥니다.',
            'fix' => 'DB 연결 직후(lib/common.lib.php 의 sql_connect 등)에 mysqli_report(MYSQLI_REPORT_OFF); 를 넣으세요.', 'code' => '');
    }

    usort($found, function ($a, $b) {
        $o = array('FATAL' => 0, 'ERROR' => 1, 'WARN' => 2, 'INFO' => 3);
        if ($o[$a['level']] !== $o[$b['level']]) return $o[$a['level']] - $o[$b['level']];
        return strcmp($a['file'], $b['file']) ?: ($a['line'] - $b['line']);
    });

    $report = array(
        'tool' => 'g5check ' . G5CHECK_VER, 'root' => $root, 'target' => $opt['target'],
        'scanned_files' => count($files), 'version' => $info,
        'running_php' => PHP_VERSION, 'php2shell' => g5c_php2shell(PHP_VERSION),
        'count' => array('FATAL' => 0, 'ERROR' => 0, 'WARN' => 0, 'INFO' => 0), 'by_dir' => array(), 'items' => $found,
    );
    foreach ($found as $f) {
        $report['count'][$f['level']]++;
        $d = $f['file'] === '(전체)' ? '(전체)' : (strpos($f['file'], '/') !== false ? implode('/', array_slice(explode('/', $f['file']), 0, 2)) : '(최상위)');
        if (!isset($report['by_dir'][$d])) $report['by_dir'][$d] = array('FATAL' => 0, 'ERROR' => 0, 'WARN' => 0, 'INFO' => 0);
        $report['by_dir'][$d][$f['level']]++;
    }

    $outStr = $opt['format'] === 'json' ? json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            : ($opt['format'] === 'md' ? g5c_md($report) : g5c_text($report));
    if ($opt['out'] !== '') { file_put_contents($opt['out'], $outStr); echo "결과를 저장했습니다: {$opt['out']}\n"; }
    else echo $outStr, "\n";
    return $report['count']['FATAL'] > 0 ? 1 : 0;
}

// 지금 이 PHP 가 PHP2Shell(CVE-2026-109618/109619) 해결 버전인지 (보호나라 2026-10-22 공지 기준)
function g5c_php2shell($v)
{
    $fixed = array('8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.27', '8.5' => '8.5.12');
    $mm = implode('.', array_slice(explode('.', $v), 0, 2));
    if (isset($fixed[$mm])) return version_compare($v, $fixed[$mm], '>=') ? '해결 버전' : '취약 — ' . $fixed[$mm] . ' 이상으로 업데이트 필요';
    if (version_compare($v, '8.2', '<')) return '취약 — 공식 패치 없음, 8.2.35 이상(권장 8.3/8.4)으로 전환 필요';
    return '확인 필요';
}

function g5c_text($r)
{
    $o = array();
    $o[] = '그누보드 PHP 8 전환 점검 — ' . $r['tool'];
    $o[] = str_repeat('=', 60);
    $o[] = '검사 폴더 : ' . $r['root'];
    $o[] = '그누보드  : ' . ($r['version']['gnuboard'] ?: '알 수 없음') . ($r['version']['youngcart'] ? '   영카트 ' . $r['version']['youngcart'] : '');
    $o[] = '목표 PHP  : ' . $r['target'] . '   (검사 파일 ' . $r['scanned_files'] . '개)';
    $o[] = '지금 PHP  : ' . $r['running_php'] . '   PHP2Shell: ' . $r['php2shell'] . '  (검사기를 실행한 PHP 기준)';
    $o[] = '';
    $o[] = sprintf('결과: 오류(FATAL) %d건 · 실행오류(ERROR) %d건 · 경고(WARN) %d건 · 참고(INFO) %d건', $r['count']['FATAL'], $r['count']['ERROR'], $r['count']['WARN'], $r['count']['INFO']);
    if ($r['by_dir']) {
        $o[] = '';
        $o[] = '[폴더별]';
        foreach ($r['by_dir'] as $d => $c) if ($c['FATAL'] || $c['WARN']) $o[] = sprintf('  %-40s FATAL %3d  WARN %3d', $d, $c['FATAL'], $c['WARN']);
    }
    $o[] = '';
    foreach ($r['items'] as $it) {
        if ($it['level'] === 'INFO' && empty($GLOBALS['g5c_verbose'])) continue;   // 참고 항목은 --verbose 때만 자세히
        $o[] = sprintf('[%s] %s%s  (PHP %s~)', $it['level'], $it['file'], $it['line'] ? ':' . $it['line'] : '', $it['since']);
        $o[] = '   ' . $it['msg'];
        if ($it['code'] !== '') $o[] = '   코드: ' . $it['code'];
        $o[] = '   조치: ' . $it['fix'];
    }
    if (!$r['items']) $o[] = '목표 버전에서 문제가 되는 코드를 찾지 못했습니다. 그래도 실제 화면 확인은 꼭 해 보세요.';
    $o[] = '';
    $o[] = '※ 정적 검사라 모든 문제를 잡지는 못합니다. PHP 8 로 올린 시험 환경에서 주요 화면·관리자 페이지를 직접 확인하세요.';
    return implode("\n", $o);
}

function g5c_md($r)
{
    $o = array();
    $o[] = '# 그누보드 PHP 8 전환 점검 결과';
    $o[] = '';
    $o[] = '| 항목 | 값 |';
    $o[] = '|---|---|';
    $o[] = '| 그누보드 | ' . ($r['version']['gnuboard'] ?: '알 수 없음') . ($r['version']['youngcart'] ? ' (영카트 ' . $r['version']['youngcart'] . ')' : '') . ' |';
    $o[] = '| 목표 PHP | ' . $r['target'] . ' |';
    $o[] = '| 검사 파일 | ' . $r['scanned_files'] . '개 |';
    $o[] = '| 지금 PHP / PHP2Shell | ' . $r['running_php'] . ' / ' . $r['php2shell'] . ' |';
    $o[] = '| 결과 | 오류 ' . $r['count']['FATAL'] . ' · 경고 ' . $r['count']['WARN'] . ' |';
    $o[] = '';
    if ($r['items']) {
        $o[] = '| 등급 | 위치 | 내용 | 조치 |';
        $o[] = '|---|---|---|---|';
        foreach ($r['items'] as $it) {
            $o[] = '| ' . $it['level'] . ' | `' . $it['file'] . ($it['line'] ? ':' . $it['line'] : '') . '` | ' . str_replace('|', '\\|', $it['msg']) . ' | ' . str_replace('|', '\\|', $it['fix']) . ' |';
        }
    }
    return implode("\n", $o);
}

function g5c_help()
{
    echo "g5check " . G5CHECK_VER . " — 그누보드·영카트 PHP 8 전환 점검기 (읽기 전용)\n\n";
    echo "사용법: php g5check.php <그누보드 폴더> [--target=8.3] [--format=text|md|json] [--out=파일] [--all] [--verbose]\n";
}

exit(g5c_main($argv));
