"""AI ATLAS: fictional school-festival data, standard-library-only analysis.
Run: python analyze_festival.py festival.csv --out results
Python >= 3.9. No data is uploaded; all processing is local.
"""
import argparse
import csv
import datetime
import hashlib
import html
import json
import io
import os
import tempfile
from collections import defaultdict
from pathlib import Path
import platform
import re

FIELDS = ['day', 'slot', 'entries', 'cups']


def read_validated(path):
    rows, errors, seen = [], [], set()
    with Path(path).open(encoding='utf-8-sig', newline='') as f:
        reader = csv.DictReader(f)
        if reader.fieldnames != FIELDS:
            raise ValueError('Expected columns: ' + ','.join(FIELDS))
        for number, row in enumerate(reader, 2):
            row_errors = []
            if None in row or any(v is None for v in row.values()):
                errors.append(f'line {number}: wrong number of columns')
                continue
            try:
                if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', row['day']):
                    raise ValueError('Expected YYYY-MM-DD')
                datetime.date.fromisoformat(row['day'])
            except ValueError:
                row_errors.append('day must be an ISO date')
            match = re.fullmatch(r'(\d{2}):00-(\d{2}):00', row['slot'])
            if not match or not (0 <= int(match[1]) < 24 and int(match[2]) == int(match[1])+1):
                row_errors.append('slot must describe a one-hour interval')
            key = (row['day'], row['slot'])
            if key in seen:
                row_errors.append('duplicate day/slot')
            seen.add(key)
            values = {}
            for field in ('entries', 'cups'):
                value = row[field].strip()
                if not re.fullmatch(r'[0-9]+', value):
                    row_errors.append(f'{field} must be a non-negative integer (blank is not zero)')
                else:
                    values[field] = int(value)
            if values.get('entries') == 0 and values.get('cups', 0) > 0:
                row_errors.append('cups > 0 with zero entries needs source verification')
            if row_errors:
                errors.extend(f'line {number}: {message}' for message in row_errors)
            else:
                rows.append(dict(day=row['day'], slot=row['slot'], **values))
    if errors:
        raise ValueError('\n'.join(errors))
    if not rows:
        raise ValueError('No data rows')
    return rows


def summarize(rows):
    grouped = defaultdict(lambda: {'entries': 0, 'cups': 0, 'hours': 0})
    for row in rows:
        group = grouped[row['day']]
        group['entries'] += row['entries']
        group['cups'] += row['cups']
        group['hours'] += 1
    daily = []
    for day in sorted(grouped):
        group = grouped[day]
        if group['entries'] == 0:
            raise ValueError(f'{day}: rate undefined because total entries is zero')
        daily.append(dict(day=day, **group,
                          cups_per_100_entries=100*group['cups']/group['entries']))
    entries = sum(row['entries'] for row in rows)
    cups = sum(row['cups'] for row in rows)
    return {'row_count': len(rows), 'daily': daily, 'total_entries': entries,
            'total_cups': cups, 'cups_per_100_entries': 100*cups/entries,
            'unweighted_daily_rate_mean': sum(r['cups_per_100_entries'] for r in daily)/len(daily)}


def chart(summary, metric, ymax, tick, unit):
    # Bar height is exactly proportional to value. Every chart starts at zero.
    width, height, left, top, bottom, right = 760, 420, 80, 45, 335, 720
    plot_height = bottom-top
    parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
             f'<title id="title">文化祭の架空データ: {html.escape(unit)}</title>',
             '<desc id="desc">日別の棒グラフ。詳しい数値と定義は本文の表にあります。</desc>',
             '<rect width="760" height="420" fill="#ffffff"/>',
             '<g font-family="system-ui, sans-serif" fill="#222a2b" font-size="27">']
    for value in range(0, ymax+1, tick):
        y = bottom-value/ymax*plot_height
        parts.extend([f'<path d="M{left} {y:.2f}H{right}" stroke="#d5dcd9"/>',
                      f'<text x="{left-15}" y="{y+7:.2f}" text-anchor="end">{value}</text>'])
    parts.append(f'<text x="{left}" y="27">{html.escape(unit)}</text>')
    colors = ['#2b4ed0', '#356c58']
    step = (right-left)/len(summary['daily'])
    for i, row in enumerate(summary['daily']):
        value = row[metric]
        barwidth = min(130, step * 0.7)
        x = left + step*(i+.5) - barwidth/2
        barheight = value/ymax*plot_height
        y = bottom-barheight
        label = f'{value:g}'
        parts.extend([f'<rect x="{x:.2f}" y="{y:.2f}" width="{barwidth}" height="{barheight:.2f}" fill="{colors[i%2]}"/>',
                      f'<text x="{x+barwidth/2:.2f}" y="{y-12:.2f}" text-anchor="middle" font-size="26" font-weight="700">{label}</text>',
                      f'<text x="{x+barwidth/2:.2f}" y="{bottom+36}" text-anchor="middle">{html.escape(row["day"][5:].replace("-","/"))}</text>'])
    parts.append('</g></svg>')
    return '\n'.join(parts)


def render_outputs(path):
    """Validate and render the whole bundle before changing any output file."""
    path = Path(path)
    summary = summarize(read_validated(path))
    # Fixed domains belong to this exercise, not a general-purpose chart tool.
    # Reject incompatible data before writing summaries or creating output dirs.
    if any(row['cups'] > 180 or row['cups_per_100_entries'] > 40 for row in summary['daily']):
        raise ValueError('Example chart range exceeded: increase ymax before drawing another dataset')
    summary['input_sha256'] = hashlib.sha256(path.read_bytes()).hexdigest()
    summary['python_version'] = platform.python_version()
    csv_text = io.StringIO(newline='')
    writer = csv.DictWriter(csv_text, fieldnames=list(summary['daily'][0]))
    writer.writeheader()
    writer.writerows(summary['daily'])
    outputs = {
        'summary.json': json.dumps(summary, ensure_ascii=False, indent=2)+'\n',
        'daily_summary.csv': csv_text.getvalue(),
        'cups-by-day.svg': chart(summary, 'cups', 180, 30, '販売杯数（杯）'),
        'rate-by-day.svg': chart(summary, 'cups_per_100_entries', 40, 10, '杯 / 入場100回'),
    }
    return summary, outputs


def write_results(path, output):
    summary, outputs = render_outputs(path)
    output = Path(output)
    # Stage all rendered files before replacing any published file. Each rename
    # is atomic, but the directory as a whole is not a database transaction.
    output.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory(prefix='.atlas-analysis-', dir=output.parent) as temporary:
        stage = Path(temporary)
        for name, text in outputs.items():
            with (stage/name).open('w', encoding='utf-8', newline='') as stream:
                stream.write(text)
        output.mkdir(parents=True, exist_ok=True)
        for name in outputs:
            os.replace(stage/name, output/name)
    return summary


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('csv_file', nargs='?', default='festival.csv')
    parser.add_argument('--out', default='results')
    args = parser.parse_args()
    try:
        summary = write_results(args.csv_file, args.out)
    except (ValueError, OSError) as exc:
        parser.exit(2, f'Analysis failed:\n{exc}\n')
    print(json.dumps(summary, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()
