#!/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,['bank_close','warehouse_close','loan_collateral','general_ledger']); facilities,dups=unique(data['facilities'],'facility_id','facilities'); expected=array(data['expected_facility_ids'],'expected_facility_ids',True)
    for ident in expected: text(ident,'expected_facility_id')
    if len(expected)!=len(set(expected)) or set(expected)!=set(facilities): raise ValueError('facility_perimeter_mismatch')
    breaks=[]; closing=0; draws=0; repayments=0
    for ident,r in facilities.items():
        known(r,cutoff,'facility'); vals={k:integer(r[k],k,0) for k in ['opening_principal_cents','draws_cents','principal_payoffs_cents','curtailments_cents','warehouse_closing_cents','collateral_advance_summary_cents','gl_principal_cents']}
        calc=vals['opening_principal_cents']+vals['draws_cents']-vals['principal_payoffs_cents']-vals['curtailments_cents']
        if calc<0: raise ValueError('negative_principal_rollforward:'+ident)
        for label in ['warehouse_closing_cents','collateral_advance_summary_cents','gl_principal_cents']:
            difference=vals[label]-calc
            if difference: breaks.append({'scope':ident,'comparison':label,'difference_cents':difference})
        closing+=calc; draws+=vals['draws_cents']; repayments+=vals['principal_payoffs_cents']+vals['curtailments_cents']
    cash=data['cash']; known(cash,cutoff,'cash')
    opening=integer(cash['opening_cents'],'cash.opening_cents'); bank_draws=integer(cash['warehouse_draw_receipts_cents'],'warehouse_draw_receipts_cents',0); bank_repayments=integer(cash['warehouse_principal_payments_cents'],'warehouse_principal_payments_cents',0); other=integer(cash['other_net_movements_cents'],'other_net_movements_cents'); bank=integer(cash['bank_closing_cents'],'bank_closing_cents'); gl=integer(cash['gl_closing_cents'],'gl_closing_cents')
    expected_cash=opening+bank_draws-bank_repayments+other
    for label,difference in [('draws_to_bank',bank_draws-draws),('repayments_to_bank',bank_repayments-repayments),('bank_cash',bank-expected_cash),('gl_cash',gl-expected_cash)]:
        if difference: breaks.append({'scope':'cash','comparison':label,'difference_cents':difference})
    minimum=integer(data['minimum_unrestricted_cash_cents'],'minimum_unrestricted_cash_cents',0)
    exceptions,exception_dups=unique(data['open_exceptions'],'exception_id','open_exceptions')
    for e in exceptions.values():
        known(e,cutoff,'exception'); text(e['owner'],'exception.owner'); day(e['due_date'],'exception.due_date'); enum(e['state'],{'open','pending_evidence'},'exception.state'); integer(e['amount_cents'],'exception.amount_cents')
    return {'status':'hold' if breaks or exceptions or bank<minimum else 'treasury_close_pack_for_review','closing_advance_principal_cents':closing,'closing_unrestricted_cash_cents':expected_cash,'cash_above_teaching_minimum_cents':bank-minimum,'breaks':breaks,'open_exception_count':len(exceptions),'duplicate_rows':dups+exception_dups,'all_covenants_certified':False,'journals_posted':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()
