#!/usr/bin/env python3
"""Reproduce Launch Tape's Odyssey example from its public JSON-lines sample.

No network calls or private data. Summary/CSV mode uses Python's standard library:
  python3 research-example-method.py one-coin-odyssey-2026-10-10-1442-1512utc.jsonl
  python3 research-example-method.py sample.jsonl --csv minute-summary.csv

Repository build (also needs Pillow and Liberation Sans fonts):
  python3 tools/research-example.py --build
Builds the two SVG charts, PNG card, method download and factual JSON/CSV outputs.
HTML is maintained separately in site/research-example.html.
"""
import argparse
import csv
import hashlib
import json
from collections import Counter
from datetime import datetime, timezone
from html import escape
from pathlib import Path

SAMPLE = 'one-coin-odyssey-2026-10-10-1442-1512utc.jsonl'
SOURCE_SHA256 = '9513e2258b8f9b4f8891fbe6f02612ad9cec9a406e9e4268bf240d1d7feeec77'
BLUE, ORANGE, BG, INK, MUTED = '#66a9ff', '#ffad66', '#0b1220', '#f3f6fc', '#b2c1d8'


def analyze(path):
    raw = path.read_bytes()
    if hashlib.sha256(raw).hexdigest() != SOURCE_SHA256:
        raise ValueError('This method reproduces the published Odyssey example; source SHA-256 does not match.')
    rows = [json.loads(line) for line in raw.splitlines() if line.strip()]
    for row in rows:
        assert row['side'] in ('buy', 'sell')
        assert all(isinstance(row[k], (int, float)) for k in ('t', 'slot', 's', 'sol', 'price', 'pool_sol'))
        assert row['price'] > 0 and row['pool_sol'] >= 0
    # Stable source order is retained within a slot. The public sample omits tx signatures
    # and instruction indices, so it cannot independently resolve a finer chain order.
    rows = [r for _, r in sorted(enumerate(rows), key=lambda p: (p[1]['slot'], p[0]))]
    assert all(a['s'] <= b['s'] for a, b in zip(rows, rows[1:])), 'sample time order changed'
    i = max((i for i, r in enumerate(rows) if r['side'] == 'sell'), key=lambda i: rows[i]['sol'])
    assert i > 0
    before, sale = rows[i - 1], rows[i]
    utc = lambda ms: datetime.fromtimestamp(ms / 1000, timezone.utc).isoformat(timespec='milliseconds')
    summary = {
        'source': SAMPLE, 'source_sha256': hashlib.sha256(raw).hexdigest(),
        'mint': 'Ff41mqYhF7CqcVBCkFifKUnzavpf9qxByHtrcgbFpump',
        'selection': 'Public Odyssey sample selected from the first 30 minutes after graduation',
        'recorded_rows': len(rows), 'sides': dict(Counter(r['side'] for r in rows)),
        'first_record_utc': utc(rows[0]['t']), 'last_record_utc': utc(rows[-1]['t']),
        'first_record_seconds': rows[0]['s'], 'last_record_seconds': rows[-1]['s'],
        'largest_recorded_sell': {
            'source_order_index_1_based': i + 1,
            'seconds_after_graduation': sale['s'], 'recorded_utc': utc(sale['t']),
            'slot': sale['slot'], 'previous_record_slot': before['slot'],
            'seller_sol_received': sale['sol'],
            'pool_sol_before_record': before['pool_sol'], 'pool_sol_after_record': sale['pool_sol'],
            'pool_sol_difference': sale['pool_sol'] - before['pool_sol'],
            'pool_sol_change_pct': 100 * (sale['pool_sol'] / before['pool_sol'] - 1),
            'pool_price_before_record': before['price'], 'pool_price_after_record': sale['price'],
            'pool_price_change_pct': 100 * (sale['price'] / before['price'] - 1),
        },
        'limitations': [
            'A selected historical coin, not a representative market study or forecast.',
            'Public sample rows are recorded trades, not proof of complete chain coverage.',
            'There is no row after +717.572 seconds in the supplied 30-minute selection; no later state is inferred.',
            's and t are source recorder timestamps; slot gives block order. Within-slot order is the source order.',
            'Pool prices are marks, not executable fills or strategy returns. No trading strategy is simulated.',
            'Seller proceeds and pool reserve change are distinct measurements; this reduced sample cannot fully reconcile fees.',
            'No wallet ownership, coordination, motive or manipulation is inferred.',
        ],
    }
    return rows, summary


def minute_csv(rows, target):
    fields = ['minute_start_seconds', 'recorded_trades', 'buys', 'sells', 'last_record_seconds',
              'last_pool_sol', 'last_pool_price_sol_per_token']
    with target.open('w', newline='') as f:
        w = csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        # Empty buckets remain empty, including the unobserved tail of the selected window.
        for minute in range(30):
            rs = [r for r in rows if minute * 60 <= r['s'] < (minute + 1) * 60]
            last = rs[-1] if rs else None
            w.writerow({'minute_start_seconds': minute * 60, 'recorded_trades': len(rs),
                        'buys': sum(r['side'] == 'buy' for r in rs), 'sells': sum(r['side'] == 'sell' for r in rs),
                        'last_record_seconds': last['s'] if last else '',
                        'last_pool_sol': last['pool_sol'] if last else '',
                        'last_pool_price_sol_per_token': last['price'] if last else ''})


def chart(rows, summary, *, price=False):
    width, height, left, top, right, bottom = 1040, 460, 76, 94, 1004, 380
    color = ORANGE if price else BLUE
    title = 'Pool price after each recorded trade' if price else 'SOL in the pool after each recorded trade'
    unit = 'SOL per 1 million tokens · pool mark' if price else 'SOL · recorded pool reserves'
    maximum = 4.5 if price else 300
    ticks = [0, 1, 2, 3, 4] if price else [0, 75, 150, 225, 300]
    x = lambda s: left + s / 720 * (right - left)
    y = lambda value: bottom - value / maximum * (bottom - top)
    value = lambda r: r['price'] * 1e6 if price else r['pool_sol']
    points = ' '.join(f'{x(r["s"]):.2f},{y(value(r)):.2f}' for r in rows)
    event = summary['largest_recorded_sell']
    idx = event['source_order_index_1_based'] - 1
    before, sale = rows[idx - 1], rows[idx]
    top_value, end_value = value(before), value(sale)
    desc = (f'{len(rows):,} recorded trades, from +{rows[0]["s"]} to +{rows[-1]["s"]} seconds. '
            f'Largest recorded sell at +{sale["s"]} seconds. '
            f'Adjacent recorded values {top_value:.3f} to {end_value:.3f}. Lines connect recorded states only.')
    out = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
           f'<title id="title">{escape(title)}</title><desc id="desc">{escape(desc)}</desc>',
           f'<rect width="1040" height="460" rx="18" fill="{BG}"/>',
           '<g font-family="Arial,Helvetica,sans-serif">',
           f'<text x="30" y="38" fill="{INK}" font-size="22" font-weight="700">{title}</text>',
           f'<text x="30" y="65" fill="{MUTED}" font-size="15">{unit}</text>']
    for tick in ticks:
        yy = y(tick)
        out += [f'<line x1="{left}" y1="{yy:.2f}" x2="{right}" y2="{yy:.2f}" stroke="#263349"/>',
                f'<text x="60" y="{yy + 5:.2f}" fill="{MUTED}" font-size="14" text-anchor="end">{tick}</text>']
    for minute in range(0, 13, 2):
        xx = x(minute * 60)
        out.append(f'<text x="{xx:.2f}" y="408" fill="{MUTED}" font-size="14" text-anchor="middle">{minute} min</text>')
    ex = x(sale['s'])
    out += [f'<line x1="{ex:.2f}" y1="94" x2="{ex:.2f}" y2="380" stroke="{color}" stroke-dasharray="4 6" opacity=".5"/>',
            f'<polyline points="{points}" fill="none" stroke="{color}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>']
    for r in [before, sale]:
        out.append(f'<circle cx="{x(r["s"]):.2f}" cy="{y(value(r)):.2f}" r="5" fill="{BG}" stroke="{color}" stroke-width="2.5"/>')
    label = f'{top_value:.3f} → {end_value:.3f}' if price else f'{top_value:.1f} → {end_value:.1f} SOL'
    pct = event['pool_price_change_pct'] if price else event['pool_sol_change_pct']
    out += [f'<rect x="{ex + 28:.2f}" y="104" width="290" height="68" rx="10" fill="#172238" stroke="#354563"/>',
            f'<text x="{ex + 44:.2f}" y="132" fill="{INK}" font-size="20" font-weight="700">{label}</text>',
            f'<text x="{ex + 44:.2f}" y="155" fill="{color}" font-size="14">{pct:.1f}% · adjacent states at +178.735 s</text>',
            f'<text x="30" y="440" fill="{MUTED}" font-size="13">Oct 10, 2026 · Odyssey · lines connect recorded states; last record at +11 min 57.6 s</text>',
            '</g></svg>']
    return '\n'.join(out) + '\n'


def share_card(rows, summary, target):
    from PIL import Image, ImageDraw, ImageFont
    scale = 2
    im = Image.new('RGB', (1200 * scale, 675 * scale), BG)
    d = ImageDraw.Draw(im)
    font_dir = Path('/usr/share/fonts/truetype/liberation')
    def font(size, bold=False):
        return ImageFont.truetype(str(font_dir / ('LiberationSans-Bold.ttf' if bold else 'LiberationSans-Regular.ttf')), round(size * scale))
    def text(x, y, s, size, color=INK, bold=False):
        d.text((x * scale, y * scale), s, fill=color, font=font(size, bold))
    def line(points, fill, width=1):
        d.line([(round(x * scale), round(y * scale)) for x, y in points], fill=fill, width=round(width * scale))
    text(52, 34, 'LAUNCH TAPE  /  RESEARCH EXAMPLE', 18, BLUE, True)
    text(52, 78, '248 → 87 SOL', 72, INK, True)
    text(56, 163, 'The pool around one recorded sell.', 28, MUTED)
    left, right, top, bottom = 85, 1118, 249, 526
    x = lambda s: left + s / 720 * (right - left)
    y = lambda sol: bottom - sol / 300 * (bottom - top)
    for value in [0, 100, 200, 300]:
        line([(left, y(value)), (right, y(value))], '#263349')
        text(40, y(value) - 10, str(value), 15, MUTED)
    line([(x(r['s']), y(r['pool_sol'])) for r in rows], BLUE, 3)
    event = summary['largest_recorded_sell']
    ex = x(event['seconds_after_graduation'])
    line([(ex, top), (ex, bottom)], ORANGE, 1)
    text(ex + 24, top + 15, '−64.9% pool SOL', 25, ORANGE, True)
    text(ex + 24, top + 49, '159.6 SOL received by the seller', 20, MUTED)
    for minute in [0, 3, 6, 9, 12]:
        text(x(minute * 60) - 15, bottom + 14, f'{minute}m', 17, MUTED)
    text(52, 592, 'Odyssey · Oct 10, 2026 · 3,001 recorded trades · selected historical case', 18, MUTED)
    text(52, 626, 'Recorded pool reserves. No strategy returns or prediction.', 18, MUTED)
    text(910, 625, 'launchtape.io', 22, BLUE, True)
    im.resize((1200, 675), Image.Resampling.LANCZOS).save(target, optimize=True)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('source', nargs='?', type=Path)
    parser.add_argument('--csv', type=Path)
    parser.add_argument('--build', action='store_true')
    args = parser.parse_args()
    root = Path(__file__).resolve().parents[1]
    source = args.source or root / 'site/sample' / SAMPLE
    rows, summary = analyze(source)
    if args.csv:
        minute_csv(rows, args.csv)
    if args.build:
        sample, img = root / 'site/research-data', root / 'site/img'
        sample.mkdir(parents=True, exist_ok=True)
        img.mkdir(parents=True, exist_ok=True)
        (sample / 'research-example-summary.json').write_text(json.dumps(summary, indent=2) + '\n')
        minute_csv(rows, sample / 'research-example-minute.csv')
        (img / 'research-example-reserves.svg').write_text(chart(rows, summary))
        (img / 'research-example-price.svg').write_text(chart(rows, summary, price=True))
        share_card(rows, summary, img / 'research-example-card.png')
        (sample / 'research-example-method.py').write_bytes(Path(__file__).read_bytes())
    print(json.dumps(summary, indent=2))


if __name__ == '__main__':
    main()
