#!/usr/bin/env python3
"""Build a pinned Open Exchange source stack and run an isolated local demo.

Python standard library only. Runtime startup and financial assertions reuse
oms/e2e/failover_e2e.py at the pinned OMS revision below, not a floating branch.
This single-host, single-Assets-node demo is not a production HA deployment.
"""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
import uuid

PINS = {
    'cluster-kit': '2b797fb215e8f60a35b6c41ca46f6846b6e21550',
    'match': '335ab240c25f077dcb135d743d39432e6f1ecf8d',
    'assets': '1726224e5d9a0b133aefde38c4ae48c1fa7a87d9',
    'oms': '8fda1431ce61659cbdf2642626253c587debf9b1',
    'admin': 'efb0c390009117ab560378a14a1f3705729e2483',
    'trading-ui': 'e1f098fa49f5a79a279f207f3ba392b1db9dda2d',
}


def run(args, cwd=None):
    subprocess.run(args, cwd=cwd, check=True)


def require_tools(names):
    missing = [name for name in names if not shutil.which(name)]
    if missing:
        raise RuntimeError('Install prerequisites first: ' + ', '.join(missing))


def build(root):
    if root.exists():
        raise RuntimeError(f'{root} already exists; choose a new empty directory name.')
    require_tools(['git', 'java', 'mvn', 'go', 'node', 'npm'])
    root.mkdir(parents=True)
    for name, sha in PINS.items():
        dest = root / name
        run(['git', 'init', '-q', str(dest)])
        run(['git', 'remote', 'add', 'origin', f'https://github.com/openexch/{name}.git'], dest)
        run(['git', 'fetch', '-q', '--depth=1', 'origin', sha], dest)
        run(['git', 'checkout', '-q', '--detach', 'FETCH_HEAD'], dest)
    for name in ['match', 'assets']:
        lock = (root / 'oms' / 'producers' / f'{name}.lock').read_text()
        assert PINS[name] == ''.join(line.strip() for line in lock.splitlines() if not line.startswith('#'))
    # Public source builds: no GitHub Packages credentials and no previously
    # installed artifacts. Override coordinates to the versions just built;
    # source revisions still exactly match the OMS producer lock files.
    mvn = ['mvn', '-B', '-ntp', f'-Dmaven.repo.local={root / ".m2"}', '-DskipTests']
    run(mvn + ['install'], root / 'cluster-kit')
    run(mvn + ['install'], root / 'match')
    run(mvn + ['install', '-Dmatch.version=1.0', '-pl', 'assets-common,assets-cluster,assets-bridge', '-am'], root / 'assets')
    run(mvn + ['package', '-Dmatch.version=1.0', '-Dassets.version=0.1.0-SNAPSHOT'], root / 'oms')
    shutil.copyfile(root / 'oms/oms-app/target/oms-app-1.0-SNAPSHOT.jar', root / 'oms/oms-app/target/oms-app.jar')
    run(['go', 'build', '-o', 'admin-gateway', '.'], root / 'admin')
    run(['npm', 'ci'], root / 'trading-ui')
    run(['npm', 'run', 'build'], root / 'trading-ui')
    (root / '.quickstart-ready.json').write_text(json.dumps(PINS, indent=2) + '\n')
    print(f'BUILD PASS: pinned sources built in {root}', flush=True)


def check_ports():
    # Check both transports before starting anything, without stopping any
    # existing process. These are this demo's fixed local ports.
    for port in [8080, 8081, 5173, *range(9000, 9600), *range(19000, 19600)]:
        for kind in [socket.SOCK_STREAM, socket.SOCK_DGRAM]:
            with socket.socket(socket.AF_INET, kind) as sock:
                try:
                    sock.bind(('0.0.0.0', port))
                except OSError as exc:
                    raise RuntimeError(f'Port {port} is busy; use a separate local machine or VM.') from exc


def start(root, check=False):
    marker = root / '.quickstart-ready.json'
    if not marker.is_file() or json.loads(marker.read_text()) != PINS:
        raise RuntimeError('Run quickstart.py build with this directory first.')
    require_tools(['java', 'psql', 'node', 'npm'])
    check_ports()
    if not os.environ.get('PGPASSWORD'):
        raise RuntimeError('Set PGPASSWORD for the dedicated local TimescaleDB demo container.')
    # Every run gets a fresh database and state directory. No reused database
    # is reset; only a database successfully created by this run is dropped.
    run_id = uuid.uuid4().hex[:12]
    state = root / f'runtime-{run_id}'
    state.mkdir()
    database = f'oe_quickstart_{run_id}'
    market_database = f'{database}_market'
    os.environ.update({
        'E2E_WORKDIR': str(state), 'E2E_PG_DB': database,
        'E2E_PG_USER': os.environ.get('PGUSER', 'quickstart'),
        'E2E_PG_HOST': os.environ.get('PGHOST', '127.0.0.1'),
        'E2E_PG_PORT': os.environ.get('PGPORT', '5432'),
        'E2E_PG_PASSWORD': os.environ['PGPASSWORD'],
        'E2E_MATCH_JAR': str(root / 'match/match-cluster/target/match-cluster.jar'),
        'E2E_ASSETS_JAR': str(root / 'assets/assets-cluster/target/assets-cluster.jar'),
        'E2E_BRIDGE_JAR': str(root / 'assets/assets-bridge/target/assets-bridge.jar'),
        'E2E_OMS_JAR': str(root / 'oms/oms-app/target/oms-app.jar'),
        'E2E_OMS_HTTP': '8080', 'E2E_OMS_GRPC': '19091',
        # The pinned market gateway uses 9000 + node*100 for cluster ingress.
        # Its client does not read CLUSTER_PORT_BASE; match and OMS must agree.
        'E2E_PORT_BASE': '9000', 'E2E_AE_PORT_BASE': '19300',
        'E2E_EGRESS_PORT': '19093', 'E2E_AE_EGRESS_PORT': '19393',
    })
    spec = importlib.util.spec_from_file_location('oe_quickstart_runtime', root / 'oms/e2e/failover_e2e.py')
    runtime = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(runtime)
    runtime.WORKDIR = str(state)
    runtime.LOGDIR = str(state / 'logs')
    Path(runtime.LOGDIR).mkdir()
    runtime.BUYER, runtime.SELLER = 1, 2
    created = []
    try:
        for name in [database, market_database]:
            runtime.psql(f'CREATE DATABASE "{name}"', db='postgres')
            created.append(name)
        runtime.psql('CREATE EXTENSION IF NOT EXISTS timescaledb', db=market_database)
        runtime.psql_file(runtime.SCHEMA_SQL)
        runtime.start_ae()
        for node in range(3):
            runtime.start_node(node)
        runtime.wait_for('matching cluster leader', runtime.find_leader, 120, interval=2)
        runtime.wait_for('Assets Engine leader', runtime.ae_ready, 120, interval=2)
        runtime.start_bridge()
        runtime.start_oms()
        runtime.wait_for('OMS connected to cluster', runtime.oms_healthy, 120, interval=2)
        # The market gateway and UI share the local API, independently of the
        # admin gateway's machine-specific process catalog and CPU pinning.
        runtime.spawn('market', runtime.java_cmd() + ['-Xms128m', '-Xmx512m',
            '-cp', str(root / 'match/match-gateway/target/match-gateway.jar'),
            'com.match.infrastructure.gateway.MarketGatewayMain'], env={
                'CLUSTER_ADDRESSES': '127.0.0.1,127.0.0.1,127.0.0.1',
                'EGRESS_PORT': '19094', 'GATEWAY_TYPE': 'market',
                'MARKET_EDGE_URL': '', 'MARKET_EDGE_TOKEN_FILE': '',
                'MARKET_PG_URL': f'jdbc:postgresql://{runtime.PG_HOST}:{runtime.PG_PORT}/{market_database}',
                'MARKET_PG_USER': runtime.PG_USER, 'MARKET_PG_PASSWORD': runtime.PG_PASSWORD,
            })
        runtime.spawn('ui', ['node', 'node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '5173', '--strictPort'],
            env={'VITE_AUTH_TOKEN': 'dev:1', 'VITE_POSTHOG_KEY': ''}, cwd=str(root / 'trading-ui'))
        def ui_ready():
            import urllib.request
            with urllib.request.urlopen('http://127.0.0.1:5173/api/v1/health', timeout=3) as response:
                return json.load(response).get('clusterConnected') is True
        runtime.wait_for('UI proxy connected to OMS', ui_ready, 60)
        def market_ready():
            import urllib.request
            with urllib.request.urlopen('http://127.0.0.1:8081/health', timeout=3) as response:
                return json.load(response).get('clusterConnected') is True
        runtime.wait_for('market data connected to cluster', market_ready, 60)
        seeds = {1: (300_000 * runtime.FP, 0), 2: (0, 5 * runtime.FP)}
        for user, amounts in seeds.items():
            for asset, amount in enumerate(amounts):
                if amount:
                    code, body = runtime.http('POST', f'/api/v1/accounts/{user}/deposit',
                        {'assetId': asset, 'amount': runtime.money(amount)}, user=user)
                    if code != 200 or not body.get('success'):
                        raise RuntimeError(f'Demo balance seed failed for user {user}: HTTP {code}')
        runtime.submit_pairs(2, 'quickstart verification', min_accept=4)
        runtime.drain_open_orders()
        runtime.wait_for('persisted executions', lambda: int(runtime.psql('SELECT count(*) FROM executions')[0]) >= 4, 60)
        time.sleep(3)
        runtime.assert_all_terminal()
        runtime.assert_fill_sums()
        runtime.assert_trade_pairing()
        runtime.assert_balances(seeds)
        runtime.assert_positions()
        runtime.wait_for('market trades persisted', lambda: int(runtime.psql('SELECT count(*) FROM trades', db=market_database)[0]) >= 2, 60)
        print('QUICKSTART PASS: 3 matching nodes, Assets Engine, settlement, orders, balances, market data and UI proxy verified.', flush=True)
        print('Open http://127.0.0.1:5173 — demo user 1. Ctrl+C stops this demo; each start uses fresh state.', flush=True)
        if not check:
            while True:
                time.sleep(1)
                if any(proc.poll() is not None for proc in runtime.procs.values()):
                    raise RuntimeError(f'A demo process stopped. Inspect {runtime.LOGDIR}')
    finally:
        runtime.teardown()
        for name in reversed(created):
            runtime.psql(f'DROP DATABASE "{name}"', db='postgres')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('action', choices=['build', 'start'])
    parser.add_argument('--directory', type=Path, default=Path('openexchange-local'))
    parser.add_argument('--check', action='store_true', help='Verify the running stack, then stop it (CI acceptance).')
    args = parser.parse_args()
    try:
        if args.action == 'build':
            build(args.directory.resolve())
        else:
            start(args.directory.resolve(), args.check)
    except KeyboardInterrupt:
        print('Demo stopped.')
    except (RuntimeError, subprocess.CalledProcessError, AssertionError) as exc:
        print(f'Quickstart: {exc}', file=sys.stderr)
        return 1
    return 0


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