#!/usr/bin/env python3
"""U50 公开入口：Spring for GraphQL 的 schema/resolver、校验、鉴权/租户、错误与 N+1。

全部测试在进程内真实 HTTP（随机端口）与真实 spring-graphql 执行引擎上运行；
H2 内存库按测试重置，数据全部合成。入口运行 `clean test`，核对 Surefire 计数、
8 条观察行与依赖版本，写入 evidence JSON。
"""
import hashlib
import json
import os
import pathlib
import platform
import re
import signal
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from datetime import datetime

ROOT = pathlib.Path(__file__).resolve().parent
EXPECTED = {
    'study.graphql.GraphqlExecutionTest': 7,
}
MARKERS = [
    'U50 schemaResolvers=',
    'U50 nPlusOne=',
    'U50 dataLoaderDedupe=',
    'U50 validationError=',
    'U50 mutationAuth=',
    'U50 graphqlValidation=',
    'U50 unhandledException=',
    'U50 httpAuth=',
]
VERSION_BOUNDS = {
    'org.springframework.graphql:spring-graphql': '2.0.6',
    'com.graphql-java:graphql-java': '25.1',
    'org.springframework.boot:spring-boot-graphql': '4.0.9',
    'org.springframework.security:spring-security-core': '7.0.8',
    'com.h2database:h2': '2.4.240',
}


def maven_binary():
    found = os.environ.get('MAVEN_BIN') or subprocess.run(
        ['/usr/bin/which', 'mvn'], capture_output=True, text=True).stdout.strip()
    if found:
        return found
    raise SystemExit('需要 Maven 3.9.9：设置 MAVEN_BIN 或在 PATH 中提供 mvn')


def interrupted(signum, _frame):
    raise SystemExit(128 + signum)


signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGINT, interrupted)

jdk = pathlib.Path(os.environ.get('JAVA_HOME') or subprocess.check_output(
    ['/usr/libexec/java_home', '-v', '21'], text=True).strip())
env = dict(os.environ, JAVA_HOME=str(jdk), PATH=str(jdk / 'bin') + os.pathsep + os.environ.get('PATH', ''))
logs_root = pathlib.Path.home() / '.cache/software-engineering-study/java-depth-impl/u50'
logs_root.mkdir(parents=True, exist_ok=True)
logs = pathlib.Path(tempfile.mkdtemp(prefix='run-', dir=logs_root))
started = datetime.now().astimezone().isoformat()
results = []
mvn = maven_binary()


def run(name, args, timeout=900, cwd=ROOT):
    log_path = logs / (name + '.log')
    process = None
    try:
        with log_path.open('w') as stream:
            process = subprocess.Popen(args, cwd=cwd, env=env, text=True,
                                       stdout=stream, stderr=subprocess.STDOUT,
                                       start_new_session=True)
            return_code = process.wait(timeout=timeout)
            exit_code = return_code if return_code >= 0 else 128 - return_code
    except subprocess.TimeoutExpired:
        exit_code = 124
        if process is not None:
            os.killpg(process.pid, signal.SIGKILL)
    output = log_path.read_text(errors='replace') if log_path.exists() else ''
    result = {'name': name, 'command': args, 'exitCode': exit_code,
              'observed': [line for line in output.splitlines() if line.startswith('U50')]}
    results.append(result)
    if exit_code:
        print(name + ' exit=' + str(exit_code) + '; raw log: ' + str(log_path)
              + '\n' + output[-3000:], file=sys.stderr, flush=True)
        raise SystemExit(exit_code)
    print('PASS ' + name, flush=True)
    return output


def surefire_reports():
    reports = []
    for path in sorted((ROOT / 'target/surefire-reports').glob('TEST-*.xml')):
        root = ET.parse(path).getroot()
        reports.append({'name': root.get('name'), 'tests': root.get('tests'),
                        'failures': root.get('failures'), 'errors': root.get('errors'),
                        'skipped': root.get('skipped'), 'time': root.get('time')})
    return reports


def resolved_versions():
    versions = {}
    tree = subprocess.run([mvn, '-B', 'dependency:tree'], cwd=ROOT, env=env,
                          capture_output=True, text=True, timeout=600).stdout
    for line in tree.splitlines():
        field = re.search(r'[(\\+|)-]*\s*([\w.]+):([\w.\-]+):jar:([\w.\-]+):(compile|runtime|test)', line)
        if field:
            key = field.group(2)
            for coord in VERSION_BOUNDS:
                if coord.split(':')[1] == key and coord not in versions:
                    versions[coord] = field.group(3)
    return versions


def file_digests():
    digests = {}
    for path in sorted(ROOT.rglob('*')):
        if not path.is_file():
            continue
        parts = path.relative_to(ROOT).parts
        if 'target' in parts or 'evidence' in parts:
            continue
        digests[str(path.relative_to(ROOT))] = hashlib.sha256(path.read_bytes()).hexdigest()
    return digests


def main():
    run('clean-test', [mvn, '-B', '-q', 'clean', 'test'])

    reports = surefire_reports()
    total = 0
    for report in reports:
        expected = EXPECTED.get(report['name'])
        if expected is None:
            raise SystemExit('未登记的报告: ' + report['name'])
        if report['tests'] != str(expected):
            raise SystemExit(f"{report['name']}: 期望 {expected}，实际 {report['tests']}")
        for key in ('failures', 'errors', 'skipped'):
            if report[key] != '0':
                raise SystemExit(f"{report['name']}: {key}={report[key]}")
        total += expected
    if set(EXPECTED) != {r['name'] for r in reports}:
        raise SystemExit('报告集合不一致')

    observed = [line for r in results for line in r['observed']]
    for marker in MARKERS:
        hits = [line for line in observed if line.startswith(marker)]
        if len(hits) != 1:
            raise SystemExit('标记应恰好出现一次: ' + marker + '，实际 ' + str(len(hits)))

    versions = resolved_versions()
    for coord, bound in VERSION_BOUNDS.items():
        actual = versions.get(coord)
        if actual != bound:
            raise SystemExit(f'{coord}: 期望 {bound}，实际 {actual}')

    evidence = {
        'ticket': 'U50 #87',
        'generatedAt': datetime.now().astimezone().isoformat(),
        'startedAt': started,
        'purpose': 'Spring for GraphQL：schema/resolver、校验、鉴权/租户、错误与 N+1/批量加载的真实 HTTP 运行',
        'exitCode': 0,
        'testCount': total,
        'reports': reports,
        'results': results,
        'observed': observed,
        'environment': {
            'os': platform.platform(),
            'arch': platform.machine(),
            'javaVersion': subprocess.check_output([str(jdk / 'bin/java'), '-version'],
                                                   stderr=subprocess.STDOUT, text=True).splitlines()[0],
            'maven': subprocess.check_output([mvn, '-v'], text=True).splitlines()[0],
            'versions': versions,
            'versionsSource': 'Spring Boot 4.0.9 parent/BOM',
            'sourceTag': 'spring-projects/spring-graphql v2.0.6（BatchLoaderRegistry/DefaultExecutionGraphQlService/AnnotatedControllerConfigurer 固定源码）',
        },
        'files': file_digests(),
        'limits': [
            '全部测试在进程内真实 HTTP（随机端口）上执行，H2 内存库；未连接生产数据库或外部 GraphQL 服务。',
            '未运行 WebSocket 订阅、SSE、multipart、RSocket transport、DataLoader maxBatchSize 分批、请求级 DataLoader 缓存跨请求（默认不跨）。',
            '未运行 GraphQL Java 的 instrumentation、分页 Connection 适配、federation、GraalVM native 与 AOT 场景。',
            'N+1 观察基于自建 SqlCounter 统计，不是数据库端 profile；未做吞吐/延迟压测。',
        ],
    }
    EVIDENCE = ROOT / 'evidence'
    EVIDENCE.mkdir(exist_ok=True)
    target = EVIDENCE / 'u50-2026-10-29.json'
    target.write_text(json.dumps(evidence, ensure_ascii=False, indent=1) + '\n')
    print('U50 PASS tests=' + str(total) + ' evidence=' + str(target.relative_to(ROOT)), flush=True)
    return 0


if __name__ == '__main__':
    sys.exit(main())
