#!/usr/bin/env python3
"""Synthetic mortgage calculation with local replay receipts. No external actions."""
import argparse
import copy
import hashlib
import json
import sqlite3
from pathlib import Path

from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP

def text(v,n):
    if type(v) is not str or not v.strip() or v!=v.strip(): raise ValueError(n+":nonblank_trimmed_text_required")
    return v

def integer(v,n,minimum=None):
    if type(v) is not int or (minimum is not None and v<minimum): raise ValueError(n+":integer_out_of_contract")
    return v

def stamp(v,n):
    text(v,n)
    d=datetime.strptime(v,"%Y-%m-%dT%H:%M:%SZ")
    if d.strftime("%Y-%m-%dT%H:%M:%SZ")!=v: raise ValueError(n+":noncanonical_utc")
    return d

def day(v,n):
    text(v,n)
    d=datetime.strptime(v,"%Y-%m-%d").date()
    if d.isoformat()!=v: raise ValueError(n+":noncanonical_date")
    return d

def array(v,n,nonempty=False):
    if type(v) is not list or (nonempty and not v): raise ValueError(n+":array_required")
    return v

def object_(v,n):
    if type(v) is not dict: raise ValueError(n+":object_required")
    return v

def enum(v,values,n):
    text(v,n)
    if v not in values: raise ValueError(n+":unsupported_value")
    return v

def boolean(v,n):
    if type(v) is not bool: raise ValueError(n+":boolean_required")
    return v

def money_round(v):
    return int(Decimal(v).quantize(Decimal("1"),rounding=ROUND_HALF_UP))

def known(row,cutoff,name):
    object_(row,name)
    if stamp(row["known_at"],name+".known_at")>cutoff: raise ValueError(name+":future_information")

def header(data,required):
    object_(data,"data")
    cutoff=stamp(data["cutoff"],"cutoff")
    enum(data["currency"],{"USD"},"currency")
    text(data["entity_id"],"entity_id")
    def perimeter(value):
        if type(value) is dict:
            if "entity_id" in value and value["entity_id"]!=data["entity_id"]: raise ValueError("wrong_entity")
            if "currency" in value and value["currency"]!=data["currency"]: raise ValueError("mixed_currency")
            for child in value.values(): perimeter(child)
        elif type(value) is list:
            for child in value: perimeter(child)
    perimeter(data)
    text(data["contract_version"],"contract_version")
    sources=array(data["sources"],"sources",True)
    found=set()
    for s in sources:
        object_(s,"source"); ident=text(s["source_id"],"source_id")
        if ident in found: raise ValueError("duplicate_source:"+ident)
        found.add(ident)
        if stamp(s["as_of"],"source.as_of")!=cutoff: raise ValueError("source_cutoff_mismatch:"+ident)
        if stamp(s["received_at"],"source.received_at")>cutoff: raise ValueError("source_not_available:"+ident)
        if boolean(s["complete"],"source.complete") is not True: raise ValueError("incomplete_source:"+ident)
        text(s["revision"],"source.revision")
    if found!=set(required): raise ValueError("required_source_set_mismatch")
    return cutoff

def unique(rows,key,name):
    result={}; duplicates=0
    for row in array(rows,name):
        object_(row,name+".row"); k=text(row[key],name+"."+key)
        if k in result:
            if row!=result[k]: raise ValueError(name+":conflicting_duplicate:"+k)
            duplicates+=1
        else: result[k]=row
    return result,duplicates

def run(data):
    try:
        return calculate(data)
    except KeyError as e:
        return {"status":"hold","errors":["missing_field:"+str(e.args[0])]}
    except (ValueError,TypeError,ArithmeticError,AttributeError) as e:
        return {"status":"hold","errors":["invalid_input:"+str(e)]}

def calculate(data):
    cutoff=header(data,['authorized_instructions','funding_cash','bank_status']); a=data['instruction']; r=data['request']; cash=data['cash']
    for name,obj in [('instruction',a),('request',r),('cash',cash)]: known(obj,cutoff,name)
    fields=['loan_id','request_key','instruction_version','payee_token','amount_cents','value_date','approval_id']
    for obj in [a,r]:
        for k in ['loan_id','request_key','payee_token','approval_id']: text(obj[k],k)
        integer(obj['instruction_version'],'instruction_version',1); integer(obj['amount_cents'],'amount_cents',1); day(obj['value_date'],'value_date')
    if any(a[k]!=r[k] for k in fields): raise ValueError('instruction_or_authority_changed')
    draw=integer(cash['draw_received_cents'],'draw_received_cents',0); equity=integer(cash['equity_available_cents'],'equity_available_cents',0)
    if draw+equity<r['amount_cents']: raise ValueError('funding_cash_shortfall')
    events,dups=unique(data['bank_events'],'event_id','bank_events'); transfer_ids=set(); sequence={}; last=None
    for ident,e in events.items():
        known(e,cutoff,'bank_event'); tid=text(e['transfer_id'],'transfer_id'); transfer_ids.add(tid)
        if e['request_key']!=r['request_key']: raise ValueError('bank_request_key_mismatch')
        seq=integer(e['sequence'],'sequence',1)
        if seq in sequence and sequence[seq]!=e: raise ValueError('conflicting_bank_sequence')
        sequence[seq]=e
        enum(e['status'],{'accepted','pending','settled','rejected','unknown'},'bank_status'); integer(e['amount_cents'],'bank.amount_cents',1); day(e['value_date'],'bank.value_date'); text(e['payee_token'],'bank.payee_token')
        if e['amount_cents']!=r['amount_cents'] or e['payee_token']!=r['payee_token'] or e['value_date']!=r['value_date']: raise ValueError('bank_instruction_mismatch')
        if e['status']=='settled' and day(e['value_date'],'bank.value_date')>cutoff.date(): raise ValueError('future_settlement_not_actual')
        if last is None or e['sequence']>last['sequence']: last=e
    if len(transfer_ids)>1: raise ValueError('multiple_transfers_for_one_instruction')
    ordered=sorted(sequence.values(),key=lambda e:e['sequence'])
    terminal=None
    for e in ordered:
        if terminal is not None and e['status']!=terminal: raise ValueError('event_after_terminal_bank_state')
        if e['status'] in {'settled','rejected'}: terminal=e['status']
    state=last['status'] if last else 'not_submitted'
    if state!='settled' and day(r['value_date'],'value_date')<cutoff.date(): raise ValueError('past_unresolved_funding_instruction')
    action={'settled':'confirmed_funded','rejected':'review_rejection_before_new_authorized_attempt','not_submitted':'prepare_authorized_release'}.get(state,'reconcile_before_any_retry')
    return {'status':'hold' if state in {'accepted','pending','unknown','rejected'} else 'funding_evidence_for_review','bank_state':state,'next_action':action,'confirmed_funding_cents':r['amount_cents'] if state=='settled' else 0,'cash_coverage_cents':draw+equity,'request_key':r['request_key'],'duplicate_events':dups,'payments_sent':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()
