#!/usr/bin/env python3
"""Check aggregate invitation batches locally. Never sends or stores survey data."""
import argparse
import csv
import datetime as dt
import re
import sys

FIELDS = ['batch_id', 'stage', 'outcome', 'survey_version', 'question_id',
          'sent_on', 'window_days', 'counts_through', 'invited', 'delivered',
          'responses', 'answered', 'positive']
OUTPUT = FIELDS + ['window_ends', 'status', 'response_pct_invited',
                   'response_pct_delivered', 'positive_pct_answered']
TOKEN = re.compile(r'[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z')


def date(value):
    if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', value or ''):
        raise ValueError('dates must use YYYY-MM-DD')
    return dt.date.fromisoformat(value)


def count(value, name, optional=False):
    if value == '' and optional:
        return None
    if not re.fullmatch(r'\d+', value or ''):
        raise ValueError(f'{name} must be a nonnegative integer' + (' or blank' if optional else ''))
    return int(value)


def pct(numerator, denominator):
    return '' if numerator is None or denominator in (None, 0) else f'{100 * numerator / denominator:.1f}'


def calculate(rows, as_of):
    results, seen, batches = [], set(), {}
    for line, row in enumerate(rows, 2):
        try:
            if set(row) != set(FIELDS) or any(v is None for v in row.values()):
                raise ValueError('row must have exactly the documented columns')
            for name in ['batch_id', 'stage', 'outcome', 'survey_version', 'question_id']:
                if not TOKEN.fullmatch(row[name]):
                    raise ValueError(f'{name} needs a 1-64 character letter/digit/underscore/hyphen identifier')
            key = row['batch_id'], row['question_id']
            if key in seen:
                raise ValueError('duplicate batch_id and question_id; reconcile, do not sum')
            seen.add(key)
            sent, through = date(row['sent_on']), date(row['counts_through'])
            days = count(row['window_days'], 'window_days')
            if not 1 <= days <= 365:
                raise ValueError('window_days must be between 1 and 365')
            end = sent + dt.timedelta(days=days)
            if sent > as_of or through != min(end, as_of):
                raise ValueError('counts_through must be the earlier of window_ends and as-of; no future batches')
            n = {name: count(row[name], name, name in ('delivered', 'answered', 'positive'))
                 for name in ['invited', 'delivered', 'responses', 'answered', 'positive']}
            if n['responses'] > n['invited']:
                raise ValueError('responses exceeds invited')
            if n['delivered'] is not None and not n['responses'] <= n['delivered'] <= n['invited']:
                raise ValueError('require responses <= delivered <= invited when delivery is known')
            if (n['answered'] is None) != (n['positive'] is None):
                raise ValueError('answered and positive must both be known or both blank')
            if n['answered'] is not None and not 0 <= n['positive'] <= n['answered'] <= n['responses']:
                raise ValueError('require positive <= answered <= responses')
            # Several questions may share a batch, but its invitation data cannot change.
            metadata = tuple(row[x] for x in FIELDS if x not in ('question_id', 'answered', 'positive'))
            if row['batch_id'] in batches and batches[row['batch_id']] != metadata:
                raise ValueError('same batch_id has conflicting invitation metadata')
            batches[row['batch_id']] = metadata
            mature = as_of >= end
            results.append({**row, 'window_ends': end.isoformat(),
                'status': 'closed_window' if mature else 'waiting_for_window',
                'response_pct_invited': pct(n['responses'], n['invited']) if mature else '',
                'response_pct_delivered': pct(n['responses'], n['delivered']) if mature else '',
                'positive_pct_answered': pct(n['positive'], n['answered']) if mature else ''})
        except (ValueError, OverflowError) as exc:
            raise ValueError(f'CSV line {line}: {exc}') from None
    return results


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('csv_file')
    parser.add_argument('--as-of', required=True, help='Reporting cutoff in YYYY-MM-DD after using one documented timezone')
    args = parser.parse_args()
    try:
        cutoff = date(args.as_of)
        with open(args.csv_file, newline='', encoding='utf-8-sig') as source:
            reader = csv.DictReader(source)
            if reader.fieldnames != FIELDS:
                raise ValueError('CSV header/order must match the supplied template')
            results = calculate(reader, cutoff)
        # Validate the whole file before writing, so a bad late row cannot emit a partial report.
        writer = csv.DictWriter(sys.stdout, fieldnames=OUTPUT, lineterminator='\n')
        writer.writeheader()
        writer.writerows(results)
    except (ValueError, OSError, csv.Error) as exc:
        print(str(exc), file=sys.stderr)
        return 2
    return 0


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