#!/usr/bin/env python3
"""
Static sanity checker for the PHP code base. Used because the build environment had no PHP interpreter.
It is NOT a substitute for `php -l` + the PHPUnit-style suite (run both before deploying), but it catches the commonest
authoring mistakes:  unbalanced brackets, missing strict_types, namespace/path/class-name mismatches, unresolved `use`
imports, calls to static methods or constants that do not exist, calls to instance methods whose name is defined nowhere.

    python3 tests/tools/php_lint.py [project_root]
"""
import os, re, sys

root = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(__file__), '..', '..'))
php_files = []
for base, dirs, files in os.walk(root):
    dirs[:] = [d for d in dirs if d not in ('vendor', '.git', 'node_modules')]
    for f in files:
        if f.endswith('.php'):
            php_files.append(os.path.join(base, f))

errors, warnings = [], []


def strip(src):
    """Remove comments and string literal CONTENT (keeps structure) so bracket counting and regexes are reliable."""
    out, i, n = [], 0, len(src)
    while i < n:
        c, d = src[i], src[i + 1] if i + 1 < n else ''
        if c == '/' and d == '/' or c == '#' and d != '[':
            while i < n and src[i] != '\n': i += 1
        elif c == '/' and d == '*':
            j = src.find('*/', i + 2); j = n if j < 0 else j + 2
            out.append('\n' * src[i:j].count('\n')); i = j
        elif c in '\'"':
            q = c; out.append(q); i += 1
            while i < n and src[i] != q:
                if src[i] == '\\': i += 1
                if i < n and src[i] == '\n': out.append('\n')
                i += 1
            out.append(q); i += 1
        elif src.startswith('<<<', i):
            m = re.match(r"<<<['\"]?(\w+)['\"]?\n", src[i:])
            if m:
                end = re.search(r'\n\s*' + m.group(1) + r'\b', src[i + len(m.group(0)):])
                j = i + len(m.group(0)) + (end.end() if end else 0)
                out.append('\n' * src[i:j].count('\n')); i = j
            else:
                out.append(c); i += 1
        else:
            out.append(c); i += 1
    return ''.join(out)


classes = {}   # FQCN -> {methods:set, consts:set, file:path, kind}
parsed = {}
for path in php_files:
    rel = os.path.relpath(path, root)
    raw = open(path, encoding='utf-8').read()
    code = strip(raw)
    parsed[path] = (raw, code)
    # 1) bracket balance
    stack = []
    pairs = {')': '(', ']': '[', '}': '{'}
    line = 1
    for ch in code:
        if ch == '\n': line += 1
        if ch in '([{': stack.append((ch, line))
        elif ch in ')]}':
            if not stack or stack[-1][0] != pairs[ch]:
                errors.append(f'{rel}:{line}: unbalanced {ch!r}'); stack = []; break
            stack.pop()
    if stack:
        errors.append(f'{rel}:{stack[-1][1]}: unclosed {stack[-1][0]!r}')
    # 2) strict_types (app + bin + routes + config)
    if not re.search(r'declare\(strict_types=1\);', raw[:400]) and not re.match(r'(tests|app/Views|admin)/', rel.replace('\\', '/')):
        errors.append(f'{rel}: missing declare(strict_types=1)')
    # 3) class discovery
    ns = re.search(r'^namespace\s+([\w\\]+);', code, re.M)
    for m in re.finditer(r'^(?:final\s+|abstract\s+)?(class|interface|trait|enum)\s+(\w+)', code, re.M):
        fq = (ns.group(1) + '\\' if ns else '') + m.group(2)
        body = code[m.end():]
        meths = set(re.findall(r'function\s+&?(\w+)\s*\(', code))
        consts = set(re.findall(r'\bconst\s+(?:\w+\s+)?(\w+)\s*=', code)) | set(re.findall(r'case\s+(\w+)\s*[=;]', code))
        props = set(re.findall(r'(?:public|private|protected)\s+(?:readonly\s+)?(?:static\s+)?(?:\??[\w\\|]+\s+)?\$(\w+)', code))
        classes[fq] = dict(methods=meths, consts=consts, props=props, file=path, kind=m.group(1))
        # namespace + path check for app/
        if rel.startswith('app' + os.sep):
            exp_ns = 'App' + ''.join('\\' + p for p in os.path.dirname(rel)[4:].split(os.sep) if p)
            if ns is None or ns.group(1) != exp_ns:
                errors.append(f'{rel}: namespace should be {exp_ns}')
            if os.path.splitext(os.path.basename(rel))[0] != m.group(2):
                errors.append(f'{rel}: class {m.group(2)} does not match file name')
        break

all_methods = set()
for c in classes.values():
    all_methods |= c['methods']

BUILTIN_METHODS = set('''prepare execute fetch fetchAll fetchColumn rowCount lastInsertId beginTransaction commit rollBack inTransaction exec query quote setAttribute
 getMessage getCode getFile getLine getTrace getPrevious format getTimestamp setTimezone modify createFromFormat setAccessible invoke invokeArgs getMethod
 add append get set has json isHttps str query header getName close open read write bindParam bindValue errorInfo
 isAbstract current key next rewind valid count offsetGet offsetSet isDot getPathname getFilename isFile isDir getRealPath getExtension'''.split())

app_class_names = {fq.split('\\')[-1]: fq for fq in classes if fq.startswith('App\\')}

for path, (raw, code) in parsed.items():
    rel = os.path.relpath(path, root)
    ns = re.search(r'^namespace\s+([\w\\]+);', code, re.M)
    cur_ns = ns.group(1) if ns else ''
    uses = {}
    for m in re.finditer(r'^use\s+([\w\\]+)(?:\s+as\s+(\w+))?;', code, re.M):
        fq = m.group(1)
        uses[m.group(2) or fq.split('\\')[-1]] = fq
        if fq.startswith('App\\') and fq not in classes:
            errors.append(f'{rel}: use {fq} does not resolve to any class in the project')
    # fully-qualified App\ references
    for m in re.finditer(r'\\App\\[\w\\]+', code):
        fq = m.group(0).lstrip('\\')
        if fq not in classes and not fq.endswith('\\'):
            errors.append(f'{rel}: reference to {fq} does not exist')
    # Static calls / constants  Foo::bar(  Foo::BAR
    for m in re.finditer(r'(?<![\w$\\>])([A-Z]\w+)::(\w+)(\()?', code):
        name, member, call = m.group(1), m.group(2), m.group(3)
        if name in ('self', 'static', 'parent', 'PDO', 'JSON_THROW_ON_ERROR') or name in ('DateTimeImmutable', 'DateTime', 'Closure', 'ReflectionMethod', 'PHP_SAPI', 'JsonException', 'DateTimeZone'):
            continue
        fq = uses.get(name) or (cur_ns + '\\' + name if (cur_ns + '\\' + name) in classes else None) or app_class_names.get(name)
        if fq is None or fq not in classes:
            if name not in ('SODIUM_CRYPTO_SECRETBOX_NONCEBYTES',) and name[0].isupper() and name not in ('E', 'T') and not name.isupper():
                if name not in ('PDO', 'Throwable') and name.islower() is False:
                    warnings.append(f'{rel}: static reference {name}::{member} - class not found in project (builtin?)')
            continue
        info = classes[fq]
        if member == 'class':
            continue
        if call:
            if member not in info['methods']:
                errors.append(f'{rel}: {name}::{member}() is not defined in {fq}')
        elif member not in info['consts'] and member not in info['props'] and info['kind'] != 'enum':
            errors.append(f'{rel}: {name}::{member} (constant) is not defined in {fq}')
    # instance method names defined nowhere
    for m in re.finditer(r'->\s*(\w+)\s*\(', code):
        name = m.group(1)
        if name not in all_methods and name not in BUILTIN_METHODS and not name.startswith('__'):
            line = code[:m.start()].count('\n') + 1
            errors.append(f'{rel}:{line}: ->{name}() is not defined in any project class')

for e in sorted(set(errors)): print('ERROR  ', e)
for w in sorted(set(warnings)): print('warning', w)
print(f'\nchecked {len(php_files)} PHP files, {len(classes)} classes: {len(set(errors))} error(s), {len(set(warnings))} warning(s)')
sys.exit(1 if errors else 0)
