#!/usr/bin/env python3
"""Synthetic Secondary Marketing calculation and local replay receipts.
No live connectors, messages, prices, orders, commitments or payments.
"""
import argparse
import copy
import hashlib
import json
import sqlite3
from pathlib import Path

def _validate(value, schema, path='input'):
    from datetime import date, datetime, timedelta
    if value is None:
        if schema.get('nullable'):return
        raise ValueError(path+': null is not allowed')
    kind=schema['kind']
    if kind=='object':
        if type(value) is not dict:raise ValueError(path+': object required')
        for key,child in schema['fields'].items():
            if key not in value:raise ValueError(path+'.'+key+': required')
            _validate(value[key],child,path+'.'+key)
    elif kind=='list':
        if type(value) is not list or len(value)<schema.get('min_items',0):raise ValueError(path+': array contract failed')
        for i,item in enumerate(value):_validate(item,schema['item'],path+'['+str(i)+']')
    elif kind=='integer':
        if type(value) is not int:raise ValueError(path+': integer required; booleans are not numbers')
        if 'minimum' in schema and value<schema['minimum']:raise ValueError(path+': below minimum')
        if 'maximum' in schema and value>schema['maximum']:raise ValueError(path+': above maximum')
    elif kind=='boolean':
        if type(value) is not bool:raise ValueError(path+': boolean required')
    elif kind in ['string','date','utc_timestamp']:
        if type(value) is not str or not value.strip():raise ValueError(path+': nonblank string required')
        if kind=='date':date.fromisoformat(value)
        if kind=='utc_timestamp':
            parsed=datetime.fromisoformat(value)
            if parsed.tzinfo is None or parsed.utcoffset()!=timedelta(0):raise ValueError(path+': explicit UTC timestamp required')
    else:raise ValueError(path+': unsupported schema type')

_INPUT_SCHEMA = {'kind': 'object', 'fields': {'as_of': {'kind': 'date'}, 'expected_source_ids': {'kind': 'list', 'item': {'kind': 'string'}, 'min_items': 1}, 'observed_source_ids': {'kind': 'list', 'item': {'kind': 'string'}}, 'counterparties': {'kind': 'list', 'item': {'kind': 'object', 'fields': {'id': {'kind': 'string'}, 'approved_from': {'kind': 'date'}, 'review_due': {'kind': 'date'}, 'status': {'kind': 'string'}, 'limit_bps': {'kind': 'integer', 'minimum': 0, 'maximum': 10000}}}, 'min_items': 1}, 'exposures': {'kind': 'list', 'item': {'kind': 'object', 'fields': {'id': {'kind': 'string'}, 'counterparty_id': {'kind': 'string'}, 'amount_cents': {'kind': 'integer'}}}}}}

def _calculate(data):
    from datetime import date
    from decimal import Decimal, ROUND_HALF_UP
    cutoff=date.fromisoformat(data['as_of'])
    if set(data['expected_source_ids'])!=set(data['observed_source_ids']):return {'status':'hold','reason':'source_coverage_incomplete','external_actions':0}
    counterparties={p['id']:p for p in data['counterparties']}
    if len(counterparties)!=len(data['counterparties']):raise ValueError('Duplicate counterparty ID')
    events={};replays=0
    for e in data['exposures']:
        if type(e['amount_cents']) is not int or e['amount_cents']<0:raise ValueError('Nonnegative integer exposure required')
        if e['id'] in events:
            if e!=events[e['id']]:return {'status':'hold','reason':'conflicting_exposure_replay','external_actions':0}
            replays+=1
        else:events[e['id']]=e
    unknown=sorted({e['counterparty_id'] for e in events.values()}-set(counterparties))
    if unknown:return {'status':'hold','reason':'unknown_counterparty','counterparty_ids':unknown,'external_actions':0}
    denominator=sum(e['amount_cents'] for e in events.values())
    if denominator<=0:return {'status':'hold','reason':'missing_positive_exposure_basis','external_actions':0}
    rows=[]
    for p in data['counterparties']:
        amount=sum(e['amount_cents'] for e in events.values() if e['counterparty_id']==p['id'])
        problems=[]
        if not date.fromisoformat(p['approved_from'])<=cutoff<=date.fromisoformat(p['review_due']) or p['status']!='approved':problems.append('approval_not_current')
        if amount*10000>denominator*p['limit_bps']:problems.append('concentration_limit')
        bps=int((Decimal(amount)*Decimal(10000)/Decimal(denominator)).quantize(Decimal(1),rounding=ROUND_HALF_UP))
        rows.append({'counterparty_id':p['id'],'amount_cents':amount,'utilization_bps':bps,'findings':problems})
    return {'status':'review_findings' if any(r['findings'] for r in rows) else 'within_stated_checks','denominator_cents':denominator,'counterparties':rows,'replay_hits':replays,'external_actions':0}


def run(data):
    try:
        _validate(data, _INPUT_SCHEMA)
        if any(p['review_due']<p['approved_from'] for p in data['counterparties']):raise ValueError('Approval review interval is malformed')
        for key in ['expected_source_ids','observed_source_ids']:
            if len(set(data[key]))!=len(data[key]):raise ValueError('Duplicate source manifest entry')
        return _calculate(data)
    except (KeyError, TypeError, ValueError, ArithmeticError):
        return {'status':'hold', 'reason':'invalid_input', 'external_actions':0}


def patched_input(bundle, case_number):
    data = copy.deepcopy(bundle["input"])
    if not case_number:
        return data, bundle["event_id"]
    case = bundle["changes"][case_number - 1]
    if "replace_input" in case:
        data = copy.deepcopy(case["replace_input"])
    else:
        path = case.get("path", case.get("append_path", case.get("remove_path")))
        if path is None:
            return data, bundle["event_id"] + ":case-" + str(case_number)
        target = data
        for key in path[:-1]:
            target = target[key]
        if "remove_path" in case:
            del target[path[-1]]
        elif "append_path" in case:
            target[path[-1]].append(copy.deepcopy(case["append_value"]))
        else:
            target[path[-1]] = copy.deepcopy(case["value"])
    return data, bundle["event_id"] + ":case-" + str(case_number)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--fixture", required=True, type=Path)
    parser.add_argument("--db", required=True, type=Path)
    parser.add_argument("--case", type=int, default=0)
    args = parser.parse_args()
    bundle = json.loads(args.fixture.read_text(encoding="utf-8"))
    if not 0 <= args.case <= len(bundle["changes"]):
        parser.error("--case must be 0 through " + str(len(bundle["changes"])))
    data, event_id = patched_input(bundle, args.case)
    process_id, policy = bundle["process_id"], bundle["policy_version"]
    if any(not isinstance(v, str) or not v.strip() for v in [process_id, policy, event_id]):
        parser.error("Process, policy and event IDs must be nonblank strings")
    raw = json.dumps(data, sort_keys=True, separators=(",", ":"), allow_nan=False)
    input_hash = hashlib.sha256(raw.encode()).hexdigest()
    code_hash = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
    with sqlite3.connect(args.db) as db:
        db.execute("""CREATE TABLE IF NOT EXISTS receipts (
            process TEXT, policy TEXT, event_id TEXT, input_hash TEXT,
            code_hash TEXT, input_json TEXT, original_result TEXT,
            status TEXT, PRIMARY KEY(process, policy, event_id))""")
        db.execute("""CREATE TABLE IF NOT EXISTS conflicts (
            process TEXT, policy TEXT, event_id TEXT, input_hash TEXT,
            code_hash TEXT, input_json TEXT,
            PRIMARY KEY(process, policy, event_id, input_hash, code_hash))""")
        db.commit()
        db.execute("BEGIN IMMEDIATE")
        key = (process_id, policy, event_id)
        old = db.execute("""SELECT input_hash,code_hash,original_result,status
            FROM receipts WHERE process=? AND policy=? AND event_id=?""", key).fetchone()
        if old and (old[0] != input_hash or old[1] != code_hash):
            result = {"status": "hold", "errors": ["conflicting_replay"]}
            action = "conflict"
            db.execute("INSERT OR IGNORE INTO conflicts VALUES (?,?,?,?,?,?)",
                       (*key, input_hash, code_hash, raw))
            db.execute("UPDATE receipts SET status='conflicted' WHERE process=? AND policy=? AND event_id=?", key)
        elif old:
            action = "reused"
            result = ({"status": "hold", "errors": ["conflicting_replay"]}
                      if old[3] == "conflicted" else json.loads(old[2]))
        else:
            action = "new"
            try:
                result = run(data)
                encoded = json.dumps(result, sort_keys=True, allow_nan=False)
            except (KeyError, ValueError, TypeError, ArithmeticError, IndexError) as error:
                result = {"status": "hold", "errors": ["invalid_input:" + type(error).__name__]}
                encoded = json.dumps(result, sort_keys=True)
            db.execute("INSERT INTO receipts VALUES (?,?,?,?,?,?,?,?)",
                       (*key, input_hash, code_hash, raw, encoded, "stored"))
        db.commit()
        count = db.execute("SELECT COUNT(*) FROM receipts WHERE process=? AND policy=?", (process_id, policy)).fetchone()[0]
    print(json.dumps({"process_id": process_id, "event_id": event_id,
          "receipt": action, "unique_receipts": count,
          "external_actions": 0, "result": result}, indent=2, allow_nan=False))


if __name__ == "__main__":
    main()
