#!/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
import json
import hashlib


def text(value, field):
    if type(value) is not str or not value.strip() or value != value.strip():
        raise ValueError(field + ":expected_nonblank_trimmed_text")
    return value


def integer(value, field, minimum=None):
    if type(value) is not int:
        raise ValueError(field + ":expected_integer")
    if minimum is not None and value < minimum:
        raise ValueError(field + ":below_minimum")
    return value


def boolean(value, field):
    if type(value) is not bool:
        raise ValueError(field + ":expected_boolean")
    return value


def obj(value, field):
    if type(value) is not dict:
        raise ValueError(field + ":expected_object")
    return value


def array(value, field):
    if type(value) is not list:
        raise ValueError(field + ":expected_array")
    return value


def timestamp(value, field):
    text(value, field)
    try:
        parsed = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
    except ValueError:
        raise ValueError(field + ":invalid_utc_timestamp") from None
    if parsed.strftime("%Y-%m-%dT%H:%M:%SZ") != value:
        raise ValueError(field + ":noncanonical_utc_timestamp")
    return parsed


def day(value, field):
    text(value, field)
    try:
        parsed = datetime.strptime(value, "%Y-%m-%d")
    except ValueError:
        raise ValueError(field + ":invalid_date") from None
    if parsed.strftime("%Y-%m-%d") != value:
        raise ValueError(field + ":noncanonical_date")
    return parsed


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


def context(data):
    obj(data, "input")
    text(data["entity"], "entity")
    if data["currency"] != "USD":
        raise ValueError("currency:synthetic_contract_requires_USD")
    cutoff = timestamp(data["cutoff"], "cutoff")
    start = timestamp(data["window_start"], "window_start")
    if start > cutoff:
        raise ValueError("window_start:after_cutoff")
    return cutoff


def rows(data, key):
    values = array(data[key], key)
    manifest = obj(data["manifest"], "manifest")
    source = obj(manifest[key], "manifest."+key)
    cutoff = timestamp(data["cutoff"], "cutoff")
    start = timestamp(data["window_start"], "window_start")
    as_of = timestamp(source["as_of"], key+".as_of")
    known = timestamp(source["known_at"], key+".known_at")
    expected = integer(source["expected_records"], key+".expected_records", 0)
    received = integer(source["received_records"], key+".received_records", 0)
    if not boolean(source["complete"], key+".complete") or expected != received or received != len(values):
        raise ValueError(key+":incomplete_population")
    if as_of != cutoff or known > cutoff:
        raise ValueError(key+":invalid_source_cutoff")
    for row in values:
        obj(row, key+".row")
        if row["entity"] != data["entity"] or row["currency"] != data["currency"]:
            raise ValueError(key+":entity_or_currency_mismatch")
        if timestamp(row["known_at"], key+".known_at") > cutoff:
            raise ValueError(key+":future_known_row")
    return values


def unique(values, key):
    result = {}
    for row in values:
        identity = text(row[key], key)
        if identity in result:
            raise ValueError(key+":duplicate_identifier")
        result[identity] = row
    return result


def checked(data, calculate):
    try:
        context(data)
        return calculate(data)
    except (KeyError, TypeError, ValueError, ArithmeticError) as error:
        detail = str(error) if type(error) is ValueError else type(error).__name__
        return {"status":"hold", "errors":["invalid_input:"+detail]}

def _calculate(data):
    events = unique(rows(data, "events"), "event_id")
    policy = obj(data["policy"], "policy")
    text(policy["version"], "policy.version")
    start, end = day(policy["valid_from"], "policy.valid_from"), day(policy["valid_to"], "policy.valid_to")
    if start > end or timestamp(policy["known_at"], "policy.known_at") > timestamp(data["cutoff"], "cutoff"):
        raise ValueError("policy:invalid_window_or_future_knowledge")
    open_periods = array(data["open_periods"], "open_periods")
    posted_keys = array(data["posted_keys"], "posted_keys")
    for value in open_periods+posted_keys:
        text(value, "registered_key")
    accounts = [text(policy[k], "policy."+k) for k in ["principal_debit","principal_credit","fee_debit","fee_credit"]]
    if policy["principal_debit"] == policy["principal_credit"] or policy["fee_debit"] == policy["fee_credit"]:
        raise ValueError("policy:same_account_on_both_sides")
    journals, errors = [], []
    for event_id, row in sorted(events.items()):
        text(row["loan_id"], "loan_id")
        revision = integer(row["revision"], "revision", 1)
        principal = integer(row["principal_cents"], "principal_cents", 0)
        fee = integer(row["approved_fee_cents"], "approved_fee_cents", 0)
        effective = day(row["event_date"], "event_date")
        if effective > timestamp(data["cutoff"], "cutoff"):
            raise ValueError("event_date:after_cutoff")
        key = data["entity"]+":"+event_id+":"+str(revision)+":"+policy["version"]
        if not start <= effective <= end:
            errors.append(event_id+":no_effective_mapping")
        if row["event_date"][:7] not in open_periods:
            errors.append(event_id+":closed_period")
        if any(prior.startswith(data["entity"]+":"+event_id+":") for prior in posted_keys):
            errors.append(event_id+":already_posted")
        lines = [{"account":accounts[0],"debit_cents":principal,"credit_cents":0},
                 {"account":accounts[1],"debit_cents":0,"credit_cents":principal},
                 {"account":accounts[2],"debit_cents":fee,"credit_cents":0},
                 {"account":accounts[3],"debit_cents":0,"credit_cents":fee}]
        journals.append({"journal_key":key,"lines":lines,"debit_cents":principal+fee,"credit_cents":principal+fee})
    return {"status":"hold" if errors else "ready_for_review", "journals":journals,
            "total_debit_cents":sum(j["debit_cents"] for j in journals), "errors":errors}


def run(data):
    return checked(data, _calculate)


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()
