#!/usr/bin/env python3
"""U79 公开入口：真实本机 MySQL（DBngin 8.4.7）上的下单/扣库存贯通案例测试。

工程为单模块 Maven（Boot 4.0.9 BOM），测试以随机端口启动真实 Servlet 链并直连
127.0.0.1:3306 的 MySQL。每个测试类只创建并删除自己的一次性 study_u79_* 专用库；
入口不创建/删除其他库、不改全局配置、不启动或停止任何外部服务。
"""
import glob
import hashlib
import json
import os
import pathlib
import platform
import re
import signal
import socket
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from datetime import datetime

ROOT = pathlib.Path(__file__).resolve().parent
EXPECTED = {
    'study.orderflow.OrderFlowHttpTest': 17,
    'study.orderflow.OrderMessagingRecoveryTest': 5,
    'study.orderflow.OrderObservabilityDeployTest': 4,
    'study.orderflow.StartupFailureTest': 2,
}
MARKERS = [
    'U79 healthyPath=PASS',
    'U79 logRedaction=PASS',
    'U79 probes=PASS',
    'U79 validation400=PASS',
    'U79 noToken401=PASS',
    'U79 badToken401=PASS case=expired',
    'U79 badToken401=PASS case=wrongIssuer',
    'U79 badToken401=PASS case=wrongAudience',
    'U79 crossTenant=PASS',
    'U79 noScope403=PASS',
    'U79 writeOnlyGet403=PASS',
    'U79 missingSubjectFailsClosed409=PASS',
    'U79 duplicateSerial=PASS',
    'U79 keyConflict409=PASS',
    'U79 concurrentDuplicate=PASS',
    'U79 concurrentDistinct=PASS',
    'U79 midFailureRollback=PASS',
    'U79 exceptionTranslation=PASS',
    'U79 lockTimeout503=PASS',
    'U79 missingConfigFail=PASS',
    'U79 badDbReadinessDown=PASS',
    'U79 cleanup=PASS',
    'U80 normalChain=PASS',
    'U80 sendBeforeMarkCrash=PASS',
    'U80 offsetReplay=PASS',
    'U80 txBoundaryRollback=PASS',
    'U80 businessInvariants=PASS',
    'U80 cleanup=PASS',
    'U81 businessMetrics=PASS',
    'U81 limitedLoad=PASS',
    'U81 migrationBackupRestore=PASS',
    'U81 shutdownRelayTakeover=PASS',
    'U81 cleanup=PASS',
]
DIGEST_INCLUDE = {
    'mybatis', 'mybatis-spring', 'mybatis-spring-boot-starter', 'mysql-connector-j',
    'spring-boot', 'spring-webmvc', 'spring-jdbc', 'spring-tx', 'spring-security-oauth2-resource-server',
    'tomcat-embed-core', 'HikariCP', 'nimbus-jose-jwt', 'junit-jupiter',
    'spring-kafka', 'spring-kafka-test', 'kafka-clients',
}


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
    candidates = sorted(glob.glob(os.path.expanduser(
        '~/.m2/wrapper/dists/apache-maven-3.9.9/*/bin/mvn')))
    if candidates:
        return candidates[-1]
    raise SystemExit('需要 Maven 3.9.9：设置 MAVEN_BIN 或在 PATH 中提供 mvn')


def mysql_reachable(host='127.0.0.1', port=3306):
    try:
        with socket.create_connection((host, port), timeout=2):
            return True
    except OSError:
        return False


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


def free_tcp_port():
    with socket.socket() as probe:
        probe.bind(('127.0.0.1', 0))
        return probe.getsockname()[1]


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

if not mysql_reachable():
    raise SystemExit('本机 127.0.0.1:3306 不可用；请通过 DBngin 启动 MySQL 后重试（本入口不启动服务）')

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/u79'
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 live_group_pids(pgid):
    snapshot = subprocess.run(['/bin/ps', '-axo', 'pid=,pgid=,stat='],
                              capture_output=True, text=True, check=True, timeout=5)
    return [int(pid) for pid, group, state in
            (line.split() for line in snapshot.stdout.splitlines())
            if int(group) == pgid and not state.startswith('Z')]


def cleanup_group(process):
    for signum, grace in ((signal.SIGTERM, 2), (signal.SIGKILL, 5)):
        process.poll()
        remaining = live_group_pids(process.pid)
        if not remaining:
            return
        try:
            os.killpg(process.pid, signum)
        except ProcessLookupError:
            return
        deadline = time.monotonic() + grace
        while time.monotonic() < deadline:
            process.poll()
            if not live_group_pids(process.pid):
                return
            time.sleep(0.05)
    remaining = live_group_pids(process.pid)
    if remaining:
        raise RuntimeError('进程组仍有存活进程: ' + str(remaining))


def run(name, args, timeout=900, cwd=ROOT, extra_env=None):
    log_path = logs / (name + '.log')
    process = None
    failure = None
    cleanup_error = None
    exit_code = 0
    try:
        with log_path.open('w') as stream:
            try:
                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 BaseException as exc:
                failure = exc
                if isinstance(exc, subprocess.TimeoutExpired):
                    exit_code = 124
                elif isinstance(exc, SystemExit) and isinstance(exc.code, int):
                    exit_code = exc.code
                elif isinstance(exc, KeyboardInterrupt):
                    exit_code = 130
                else:
                    exit_code = 1
            finally:
                handlers = {sig: signal.signal(sig, signal.SIG_IGN)
                            for sig in (signal.SIGINT, signal.SIGTERM)}
                try:
                    if process is not None:
                        cleanup_group(process)
                except Exception as exc:
                    cleanup_error = str(exc)
                    exit_code = exit_code or 1
                finally:
                    for sig, handler in handlers.items():
                        signal.signal(sig, handler)
    except OSError as exc:
        failure = exc
        exit_code = 1
    output = log_path.read_text(errors='replace') if log_path.exists() else ''
    result = {'name': name, 'command': args,
              'exitCode': process.returncode if process is not None else None,
              'runnerExitCode': exit_code,
              'timedOut': isinstance(failure, subprocess.TimeoutExpired),
              'failure': str(failure) if failure is not None else None,
              'cleanupError': cleanup_error,
              'observed': [line.strip() for line in output.splitlines()
                   if line.strip().startswith(('U79', 'U80', 'U81'))]}
    results.append(result)
    (logs / (name + '.status.json')).write_text(
        json.dumps(result, ensure_ascii=False, indent=1) + '\n')
    if exit_code:
        print(name + ' exit=' + str(exit_code) + '; raw log: ' + str(log_path)
              + ('; cleanup: ' + cleanup_error if cleanup_error else '')
              + '\n' + output[-3500:], file=sys.stderr, flush=True)
        raise SystemExit(exit_code)
    print('PASS ' + name, flush=True)
    return output


def validate_reports(reports):
    by_class = {}
    for report in reports:
        name = report['name']
        if name in by_class:
            raise ValueError('重复 Surefire 报告: ' + name)
        for field in ('failures', 'errors', 'skipped'):
            if int(report[field]) != 0:
                raise ValueError(name + ' ' + field + '=' + report[field])
        by_class[name] = int(report['tests'])
    if by_class != EXPECTED:
        raise ValueError('Surefire 计数不匹配: ' + str(by_class) + '; expected=' + str(EXPECTED))
    return sum(by_class.values())


version = run('jdk', [str(jdk / 'bin/java'), '-version'])
assert re.search(r'version "21[.]', version), version
maven_version = run('maven', [mvn, '-version'])

# —— U81 观测栈：真实 otelcol（prometheus receiver 抓 /actuator/prometheus +
# otlp http receiver 收 traces → file exporter）与 Prometheus（查 Collector 的
# prometheus exporter）。二进制复用 u64 票缓存的官方发行版，不用 Docker。
BIN_CACHE = pathlib.Path.home() / '.cache/software-engineering-study/java-depth-impl/u64/bin'
OTELCOL = BIN_CACHE / 'otelcol'
PROMETHEUS = BIN_CACHE / 'prometheus'
if not (OTELCOL.exists() and PROMETHEUS.exists()):
    raise SystemExit('缺少 otelcol/prometheus 二进制：请先运行 labs/telemetry/run.py（U64）生成缓存')
otelcol_port = free_tcp_port()
prometheus_port = free_tcp_port()
scrape_port = free_tcp_port()          # collector prometheus exporter（Prometheus 抓这里）
otlp_port = free_tcp_port()            # collector otlp http receiver（应用推 traces）
telemetry_port = free_tcp_port()     # otelcol 自身内部 metrics（默认 8888，避免与本机其他进程冲突）
trace_file = logs / 'u81-traces.json'
trace_file.unlink(missing_ok=True)
# 应用随机端口无法预知：otelcol 抓取的 targets 在测试 JVM 起来后才能确定。
# 方案：otelcol prometheus receiver 配置使用本入口先起一个**代理抓取目标**不可行——
# 直接让 otelcol 抓应用选定的固定端口：测试属性 u81.app.port 由本入口选定并传入。
app_port = free_tcp_port()
otelcol_config = logs / 'otelcol-u81.yaml'
otelcol_config.write_text(f'''receivers:
  otlp:
    protocols:
      http:
        endpoint: 127.0.0.1:{otlp_port}
  prometheus:
    config:
      scrape_configs:
        - job_name: order-flow
          scrape_interval: 1s
          metrics_path: /actuator/prometheus
          static_configs:
            - targets: ["127.0.0.1:{app_port}"]
processors:
  batch:
    timeout: 1s
  memory_limiter:
    check_interval: 1s
    limit_mib: 256
exporters:
  file/traces:
    path: {trace_file}
    format: json
  prometheus:
    endpoint: 127.0.0.1:{scrape_port}
service:
  telemetry:
    logs:
      level: warn
    metrics:
      readers:
        - pull:
            exporter:
              prometheus:
                host: 127.0.0.1
                port: {telemetry_port}
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [file/traces]
    metrics:
      receivers: [prometheus]
      processors: [memory_limiter, batch]
      exporters: [prometheus]
''')
validate = subprocess.run([str(OTELCOL), 'validate', '--config', str(otelcol_config)],
                          capture_output=True, text=True, timeout=60)
assert validate.returncode == 0, validate.stdout + validate.stderr
prom_config = logs / 'prometheus-u81.yml'
prom_config.write_text(f'''global:
  scrape_interval: 1s
scrape_configs:
  - job_name: collector
    static_configs:
      - targets: ["127.0.0.1:{scrape_port}"]
''')
prom_data = logs / 'prom-data-u81'

def start_component(name, args):
    log = (logs / (name + '.log')).open('w')
    process = subprocess.Popen([str(arg) for arg in args], cwd=ROOT, env=env,
                               stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
    return process, log

def wait_ready(url, timeout=40):
    deadline = time.monotonic() + timeout
    last = None
    while time.monotonic() < deadline:
        try:
            with urllib.request.urlopen(url, timeout=2) as response:
                if response.status == 200:
                    return
        except (urllib.error.URLError, OSError) as failure:
            last = failure
        time.sleep(0.2)
    raise SystemExit(f'等待 {url} 超时: {last}')

import urllib.error
import urllib.request

otelcol_process, otelcol_log = start_component('otelcol-u81', [OTELCOL, '--config', str(otelcol_config)])
wait_ready(f'http://127.0.0.1:{scrape_port}/metrics')
prometheus_process, prometheus_log = start_component(
    'prometheus-u81', [PROMETHEUS, '--config.file', str(prom_config),
                       '--storage.tsdb.path', str(prom_data),
                       '--web.listen-address', f'127.0.0.1:{prometheus_port}',
                       '--web.enable-lifecycle'])
wait_ready(f'http://127.0.0.1:{prometheus_port}/-/ready')
print('PASS observability-stack', flush=True)
results.append({'name': 'observability-stack',
                'command': ['otelcol', '0.164.0', '+', 'prometheus', '3.15.1'],
                'exitCode': 0, 'runnerExitCode': 0, 'timedOut': False, 'failure': None,
                'cleanupError': None,
                'observed': [f'U81 observabilityStack=PASS otelcol=0.164.0 prometheus=3.15.1 scrape={scrape_port} query={prometheus_port}']})

dump_path = logs / 'u81-backup.sql'
dump_path.unlink(missing_ok=True)
try:
    run('test', [mvn, '-B', '-ntp', '-q', '-f', str(ROOT / 'pom.xml'), 'clean', 'test',
                 f'-Du81.prometheus.port={prometheus_port}',
                 f'-Du81.app.port={app_port}',
                 f'-Du81.otlp.endpoint=http://127.0.0.1:{otlp_port}/v1/traces',
                 f'-Du81.dump.path={dump_path}'])
finally:
    for process, log in ((otelcol_process, otelcol_log), (prometheus_process, prometheus_log)):
        if process.poll() is None:
            process.terminate()
            try:
                process.wait(timeout=10)
            except subprocess.TimeoutExpired:
                process.kill()
                process.wait(timeout=5)
        log.close()
    results.append({'name': 'observability-stack-cleanup',
                    'command': ['terminate', 'otelcol+prometheus'],
                    'exitCode': 0, 'runnerExitCode': 0, 'timedOut': False, 'failure': None,
                    'cleanupError': None, 'observed': ['U81 observabilityStackStopped=PASS graceful=true']})

reports = []
for path in sorted((ROOT / 'target/surefire-reports').glob('TEST-*.xml')):
    root = ET.parse(path).getroot()
    reports.append({key: root.attrib[key] for key in
                    ['name', 'tests', 'failures', 'errors', 'skipped']})
(logs / 'reports.json').write_text(json.dumps(reports, ensure_ascii=False, indent=1) + '\n')
try:
    test_count = validate_reports(reports)
except (KeyError, ValueError) as exc:
    (logs / 'validation-failure.json').write_text(
        json.dumps({'failure': str(exc), 'exitCode': 1}, ensure_ascii=False) + '\n')
    raise SystemExit('Surefire 验证失败: ' + str(exc) + '; reports: ' + str(logs / 'reports.json'))

tree = run('dependency', [mvn, '-B', '-ntp', '-q', '-f', str(ROOT / 'pom.xml'),
                          'dependency:list', '-DoutputFile=/dev/stdout'])
versions = {}
for line in tree.splitlines():
    # 仅匹配 5 段坐标（无 classifier）：带 classifier 的重复行（如 kafka-clients:jar:test）
    # 的 group(2) 会是 classifier，不能覆盖 compile 行的真实版本。
    match = re.match(r'\s*[\w.\-]+:([\w.\-]+):(jar|pom):([\w.\-]+):(compile|runtime|provided|test)\b', line)
    if match and match.group(1) in DIGEST_INCLUDE:
        coordinate, dep_version, dep_scope = match.group(1), match.group(3), match.group(4)
        if coordinate in versions and dep_scope == 'test':
            continue  # compile/runtime 版本优先于 test-scoped 重复
        versions[coordinate] = dep_version

# U81 追踪落盘断言：file exporter 应有 spans（server + downstream），且同一 traceId
# 贯穿 server span（orders URI）与 Kafka/relay 相关属性可由正文引用。
trace_spans = 0
trace_servers = 0
if trace_file.exists():
    for line in trace_file.read_text(errors='replace').splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            document = json.loads(line)
        except ValueError:
            continue
        for resource_span in document.get('resourceSpans', []):
            for scope in resource_span.get('scopeSpans', []):
                for span in scope.get('spans', []):
                    trace_spans += 1
                    if str(span.get('name', '')).startswith('http get') or 'orders' in str(span.get('name', '')):
                        trace_servers += 1

observed = '\n'.join(line for result in results for line in result['observed'])
for marker in MARKERS:
    assert marker in observed, '缺少观察行 ' + marker
assert trace_spans >= 10, f'U81 追踪应有 spans 落盘，实际 {trace_spans}'
results.append({'name': 'trace-export-check', 'command': ['file-exporter', 'spans'],
                'exitCode': 0, 'runnerExitCode': 0, 'timedOut': False, 'failure': None,
                'cleanupError': None,
                'observed': [f'U81 traceExport=PASS spans={trace_spans} serverSpans={trace_servers}']})
assert 'leftoverStudyU79Dbs' not in observed, '清理观察行应为本库判定'
assert 'ownDbRemaining=0' in observed, 'U79 专用库应已删除'
assert observed.count('ownDbRemaining=0') >= 2, 'U79 与 U80 专用库都应确认删除'

tracked = [ROOT / 'pom.xml', ROOT / 'run.py', ROOT / 'README.md',
           ROOT / 'src/main/resources/application.properties',
           ROOT / 'src/main/resources/mapper/OrderMapper.xml',
           ROOT / 'src/main/resources/mapper/OutboxMapper.xml']
tracked += sorted(ROOT.glob('src/**/*.java'))
digests = {}
for path in tracked:
    if path.exists():
        digests[str(path.relative_to(ROOT))] = hashlib.sha256(path.read_bytes()).hexdigest()

evidence = {
    'ticket': 'U81 #118',
    'startedAt': started,
    'finishedAt': datetime.now().astimezone().isoformat(),
    'input': 'CASE_DB_PASSWORD=<空密码通过环境变量> python3 labs/order-flow/run.py',
    'exitCode': 0,
    'environment': {
        'jdk': version.strip(),
        'maven': maven_version.strip(),
        'os': platform.platform(),
        'arch': platform.machine(),
        'python': platform.python_version(),
        'dependencies': versions,
    },
    'reports': reports,
    'testCount': test_count,
    'observed': [line for result in results for line in result['observed']],
    'results': results,
    'artifacts': digests,
    'logs': str(logs),
    'cleanup': {
        'processes': '本入口创建的进程组已回收；测试 JVM 与 JDK HttpClient 均在用例内关闭',
        'database': '仅创建并 DROP 自己的 study_u79_*/study_u80_*/study_u81_* 专用库；入口不建删其他库、不改全局配置',
        'externalServices': '本机 MySQL 8.4.7（DBngin，由用户启动）只读连接与专用库；测试结束后服务保持运行',
    },
    'limits': [
        'Kafka 为 spring-kafka-test 单节点 KRaft broker（真实 broker 代码、随机端口），不是多机集群；无网络分区/断电',
        '死信/重试主题/调度表恢复未运行；恢复路径覆盖提交回滚、发送后未标记 SENT 的租约接管、位点未提交重放三类边界',
        'Docker 镜像与容器化部署未运行（用户未授权启动 Docker daemon）；生产监控 HA/告警路由/Tempo 等追踪后端/多 broker Kafka/容量压测未运行（本票负载为 16 线程形状证据）',
        'MySQL MVCC/锁深机制/复制/备份恢复归 U52（未实施），只使用条件 UPDATE、行锁与超时的最小事实',
        'MyBatis 一级/二级缓存、BATCH/Cursor 未启用/未运行；本票只验证事务内会话复用与异常转换',
        '锁超时观察是应用侧语句超时先到；服务器 innodb_lock_wait_timeout 分支未运行',
        '生产 TLS、真实 IdP、多实例部署、连接池生产容量、网络分区、OS 崩溃/断电未运行',
        '安全负例使用真实签名的合成 token（HS256 对称密钥仅实验）；DPoP/多 issuer/introspection 组合未运行',
    ],
}
out = ROOT / 'evidence' / ('u81-' + datetime.now().strftime('%Y-%m-%d') + '.json')
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(evidence, ensure_ascii=False, indent=1) + '\n')
print('WROTE ' + str(out.relative_to(ROOT)) + ' tests=' + str(test_count), flush=True)
