#!/usr/bin/env python3
"""Verify frozen artwork and rebuild three CSVs using only Python's standard library.

Extract source-and-data.zip first. This script never fetches data or renders art.
"""
import argparse
import csv
from decimal import Decimal, ROUND_HALF_UP, localcontext
from fractions import Fraction
from hashlib import sha256
import io
import json
from pathlib import Path
import re
import struct
from xml.etree import ElementTree as ET

IDS = ('CUUR0000SAF11', 'CUUR0000SEFV')
RAW = {
    'cu.data.11.USFoodBeverage': '4f67a71ae4c745b97e70fcff986c69dd9de130312e873d7e32f02a948309d3cc',
    'cu.series': '96d68bb334cc2a4bc3b4a9509179dc9dc692d60c3b727c30431278753cdcc68f',
    'cu.footnote': 'd92c2f0b60f9a52f9184f366f9d5b61fc6659181959be718fa332e43f9983903',
    'cpi-release-september-2026.html': '83f99d40e27b9d4d8e27f0548137d34157e2b3236e2630e0b7f9ce689a8f9923',
}
ART = {
    'chart.svg': '301e352dc0f2ebdd62b4da51528a62351d7e62147bf2e73c9d24e4dff7404387',
    'chart_mobile.svg': '5cf1bed7089111d422f5f41236a44253b21589954f5b834835dba91307412154',
    'chart.png': '53d0e867b152bf816bf9b1104da268c9c69828c73cf401514d1d06cd3e83ad56',
    'chart_mobile.png': '3f700a52f591fd3d4f876d73a76078d3c1744c9742743933a6152816a1ab63b5',
}
CSV = {
    'monthly.csv': '65012b1aa4ed58e8092aea87df2b8cb9bad2bc17d05a9a6da9be674631881aff',
    'source-extract.csv': '880d8fd56a33d28cc16ae66db9dd9a150358aa27bbc0d6246e2c65caf872a993',
    'period-changes.csv': 'c58be7296741facf9291206276c241ba725f93f39e095fef56f050da875e7061',
}
CAPTURE_SHA256 = '7359a6fe2489aa1aa74ebc49886f96fc70185ced834e96fd75f365b8eedcdca3'


def require(condition, message):
    if not condition:
        raise ValueError(message)


def hashed(data):
    return sha256(data).hexdigest()


def read_checked(base, name, expected):
    require((base / name).is_file(), 'Required package file missing: ' + name)
    data = (base / name).read_bytes()
    require(hashed(data) == expected, 'SHA-256 mismatch: ' + name)
    return data


def table(data):
    return [{k.strip(): v.strip() for k, v in row.items()}
            for row in csv.DictReader(io.StringIO(data.decode('utf-8')), delimiter='\t')]


def csv_bytes(header, rows):
    stream = io.StringIO(newline='')
    writer = csv.writer(stream, lineterminator='\n')
    writer.writerow(header)
    writer.writerows(rows)
    return stream.getvalue().encode('utf-8')


def rebuild(base):
    raw = {name: read_checked(base, 'raw/' + name, digest) for name, digest in RAW.items()}
    capture = json.loads(read_checked(base, 'SOURCE_PROVENANCE.json', CAPTURE_SHA256))
    require({s['file']: s['sha256'] for s in capture['sources']} == RAW,
            'Capture provenance must identify the four frozen sources')
    require(all(s['bytes'] == len(raw[s['file']]) for s in capture['sources']),
            'Source byte lengths must match provenance')
    series = {r['series_id']: r for r in table(raw['cu.series']) if r['series_id'] in IDS}
    metadata = json.loads((base / 'metadata.json').read_text(encoding='utf-8'))
    for sid in IDS:
        require(series[sid] == metadata['series'][sid], 'Frozen series definition mismatch: ' + sid)
        require(series[sid]['area_code'] == '0000' and series[sid]['seasonal'] == 'U'
                and series[sid]['periodicity_code'] == 'R' and series[sid]['base_period'] == '1982-84=100',
                'Expected U.S. city average monthly CPI-U, not seasonally adjusted')
    footnotes = {r['footnote_code']: r['footnote_text'] for r in table(raw['cu.footnote'])}
    require(footnotes['X'] == 'Data unavailable due to the 2025 lapse in appropriations',
            'Unexpected missing-data footnote')
    release = raw['cpi-release-september-2026.html'].decode('utf-8')
    require('CONSUMER PRICE INDEX - SEPTEMBER 2026' in release
            and 'Wednesday, October 14, 2026' in release, 'Frozen source release date mismatch')
    months = [f'{2019 + (8 + i) // 12:04d}-{(8 + i) % 12 + 1:02d}' for i in range(85)]
    source_rows = [r for r in table(raw['cu.data.11.USFoodBeverage']) if r['series_id'] in IDS]
    source_map = {(r['series_id'], r['year'], r['period']): r for r in source_rows}
    require(len(source_map) == len(source_rows), 'Duplicate source observations')
    selected = {sid: [source_map[(sid, m[:4], 'M' + m[-2:])] for m in months] for sid in IDS}
    values = {sid: [None if r['value'] == '-' else Decimal(r['value']) for r in selected[sid]]
              for sid in IDS}
    for sid in IDS:
        require([i for i, v in enumerate(values[sid]) if v is None] == [73],
                'Only October 2025 may be missing: ' + sid)
        require(selected[sid][73]['footnote_codes'] == 'X', 'Missing source cell requires footnote X')
        require(all(r['footnote_codes'] == '' for i, r in enumerate(selected[sid]) if i != 73),
                'Unexpected nonmissing observation footnote')
    with localcontext() as context:
        context.prec = 28
        rebased = {sid: [v / values[sid][0] * 100 if v is not None else None for v in values[sid]]
                   for sid in IDS}
        monthly = []
        for i, month in enumerate(months):
            monthly.append([month]
                           + [str(values[sid][i]) if values[sid][i] is not None else '' for sid in IDS]
                           + [format(rebased[sid][i], '.9f') if rebased[sid][i] is not None else '' for sid in IDS]
                           + ['X' if i == 73 else ''])
        periods = []
        for a, b, years in [(0, 36, 3), (36, 84, 4), (0, 84, 7)]:
            changes = [str(((values[sid][b] / values[sid][a] - 1) * 100)
                           .quantize(Decimal('.1'), rounding=ROUND_HALF_UP)) for sid in IDS]
            periods.append([months[a], months[b], years] + changes)
    output = {
        'monthly.csv': csv_bytes(['month', 'food_at_home_cpi_1982_84_100',
                                  'food_away_cpi_1982_84_100', 'food_at_home_sept_2019_100',
                                  'food_away_sept_2019_100', 'footnote_code'], monthly),
        'source-extract.csv': csv_bytes(['series_id', 'year', 'period', 'value', 'footnote_codes'],
                                       [[r[k] for k in ['series_id', 'year', 'period', 'value', 'footnote_codes']]
                                        for sid in IDS for r in selected[sid]]),
        'period-changes.csv': csv_bytes(['start_month', 'end_month', 'years',
                                         'food_at_home_percent', 'food_away_percent'], periods),
    }
    for name, data in output.items():
        require(hashed(data) == CSV[name], 'Rebuilt CSV mismatch: ' + name)
        require(read_checked(base, name, CSV[name]) == data, 'Packaged CSV differs from rebuilt data: ' + name)
    return output, values


def verify_art(base, values):
    art = {name: read_checked(base, name, digest) for name, digest in ART.items()}
    point_count = 0
    namespace = {'s': 'http://www.w3.org/2000/svg'}
    for name, width, height, left, right, top, bottom in [
            ('chart.svg', 1200, 920, 72, 942, 242, 600),
            ('chart_mobile.svg', 360, 950, 42, 332, 222, 502)]:
        doc = ET.fromstring(art[name])
        require(doc.get('viewBox') == f'0 0 {width} {height}' and doc.get('role') == 'img',
                'SVG viewport or accessible role mismatch: ' + name)
        require(doc.get('aria-labelledby') == 'title desc'
                and len(doc.find('s:desc', namespace).text) > 100, 'SVG accessible description missing: ' + name)
        paths = [p for p in doc.findall('.//s:path', namespace) if p.get('data-series')]
        require(len(paths) == 4, 'SVG must have four disconnected plot segments: ' + name)
        for sid in IDS:
            segments = []
            for path in [p for p in paths if p.get('data-series') == sid]:
                d = path.get('d')
                require(re.sub(r'[ML][-\d.]+,[-\d.]+', '', d).strip() == '',
                        'Unexpected SVG path command: ' + name)
                segments.append(re.findall(r'([ML])([-\d.]+),([-\d.]+)', d))
            require([len(s) for s in segments] == [73, 11] and all(s[0][0] == 'M' for s in segments),
                    'Missing-month SVG gap must remain explicit: ' + name)
            require(all(t[0] == 'L' for s in segments for t in s[1:]), 'Unexpected SVG move: ' + name)
            for i, (_, x, y) in zip(list(range(73)) + list(range(74, 85)), segments[0] + segments[1]):
                expected_x = Fraction(left) + Fraction(i, 84) * (right - left)
                expected_y = Fraction(bottom) - (Fraction(values[sid][i]) / Fraction(values[sid][0]) * 100 - 100) * Fraction(bottom - top, 45)
                require(abs(Fraction(x) - expected_x) <= Fraction(1, 2000)
                        and abs(Fraction(y) - expected_y) <= Fraction(1, 2000),
                        'Source-matched SVG vertex mismatch: ' + name + '/' + sid)
                point_count += 1
    for name, dimensions in [('chart.png', (1800, 1380)), ('chart_mobile.png', (360, 950))]:
        require(art[name][:8] == b'\x89PNG\r\n\x1a\n'
                and struct.unpack('>II', art[name][16:24]) == dimensions,
                'Frozen PNG dimensions mismatch: ' + name)
    return point_count


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--source-dir', type=Path, default=Path(__file__).resolve().parent,
                        help='Extracted bundle folder containing raw/ and packaged files')
    parser.add_argument('--output', type=Path, help='New directory to create for the three rebuilt CSVs')
    parser.add_argument('--verify-only', action='store_true', help='Verify without writing files')
    args = parser.parse_args()
    require(not (args.verify_only and args.output), 'Choose --verify-only or --output')
    output, values = rebuild(args.source_dir.resolve())
    points = verify_art(args.source_dir.resolve(), values)
    if args.output:
        require(not args.output.exists(), 'Output directory must not already exist')
        args.output.mkdir(parents=True)
        for name, data in output.items():
            (args.output / name).write_bytes(data)
    print(json.dumps({'status': 'PASS', 'csvFilesRebuilt': 3, 'exactCSVHashes': CSV,
                      'frozenSourceHashesVerified': 4, 'frozenArtworkHashesVerified': 4,
                      'svgVerticesVerified': points, 'monthlySlots': 85,
                      'extractedSourceRows': 170, 'observedValues': 168, 'missingCells': 2,
                      'missingMonth': '2025-10', 'interpolatedValues': 0,
                      'artworkReRendered': False, 'networkRequests': 0,
                      'csvFilesWritten': 3 if args.output else 0}, indent=2))


if __name__ == '__main__':
    try:
        main()
    except (ValueError, KeyError, OSError, ET.ParseError) as error:
        raise SystemExit('Verification failed: ' + (str(error) if isinstance(error, (ValueError, KeyError))
                                                 else 'Package input could not be read or parsed'))
