#!/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

from datetime import datetime


def _object(value, name):
    if type(value) is not dict:
        raise ValueError(name+":expected_object")


def _array(value, name, nonempty=False):
    if type(value) is not list or (nonempty and not value):
        raise ValueError(name+":expected_"+("nonempty_" if nonempty else "")+"array")


def _text(value, name):
    if type(value) is not str or not value.strip() or value != value.strip():
        raise ValueError(name+":expected_trimmed_nonblank_string")


def _integer(value, name, minimum=None, maximum=None):
    if type(value) is not int:
        raise ValueError(name+":expected_integer")
    if minimum is not None and value < minimum:
        raise ValueError(name+":below_minimum")
    if maximum is not None and value > maximum:
        raise ValueError(name+":above_maximum")


def _boolean(value, name):
    if type(value) is not bool:
        raise ValueError(name+":expected_boolean")


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


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


def _enum(value, allowed, name):
    _text(value, name)
    if value not in allowed:
        raise ValueError(name+":unsupported_value")


def _strings(values, name, nonempty=True):
    _array(values, name, nonempty)
    for value in values:
        _text(value, name)
    if len(values) != len(set(values)):
        raise ValueError(name+":duplicate_identifier")


def _validate(data):
    _object(data,"data")
    _utc(data["as_of"],"as_of")
    _text(data["loan_id"],"loan_id")
    _text(data["lock_id"],"lock_id")
    _array(data["events"],"events")
    seen = {}
    previous_time = None
    for e in data["events"]:
        _object(e,"event")
        for key in ["event_id","loan_id","lock_id","request_id"]:
            _text(e[key],"event."+key)
        _utc(e["observed_at"],"event.observed_at")
        _enum(e["kind"],{"requested","confirmed"},"event.kind")
        if e["kind"] == "confirmed":
            _integer(e["revision"],"event.revision",1)
            _text(e["authority_reference"],"event.authority_reference")
            _text(e["confirmation_id"],"event.confirmation_id")
            _utc(e["expires_at"],"event.expires_at")
        else:
            _utc(e["requested_expires_at"],"event.requested_expires_at")
        # Exact or conflicting repeated IDs are reconciled by the calculation.
        # New events must be supplied in the authoritative ordered history.
        if e["event_id"] not in seen:
            if previous_time is not None and e["observed_at"] < previous_time:
                raise ValueError("events:unordered_history")
            previous_time = e["observed_at"]
        seen[e["event_id"]] = e


def _calculate(data):
    events = {}
    errors = []
    pending = set()
    confirmed = None
    for e in data["events"]:
        if e["event_id"] in events:
            if events[e["event_id"]] != e:
                errors.append("conflicting_event:"+e["event_id"])
            continue
        events[e["event_id"]] = e
        if e["loan_id"] != data["loan_id"] or e["lock_id"] != data["lock_id"]:
            errors.append("identity_mismatch:"+e["event_id"])
            continue
        if e["observed_at"] > data["as_of"]:
            continue
        if e["kind"] == "requested":
            pending.add(e["request_id"])
        elif e["kind"] == "confirmed":
            expected = 1 if confirmed is None else confirmed["revision"]+1
            if e["revision"] != expected or not e.get("authority_reference") or not e.get("confirmation_id"):
                errors.append("confirmation_gate:"+e["event_id"])
                continue
            confirmed = e
            pending.discard(e["request_id"])
        else:
            errors.append("unsupported_event:"+e["event_id"])
    if confirmed is None:
        errors.append("no_confirmed_lock")
    elif confirmed["expires_at"] <= data["as_of"]:
        errors.append("confirmed_lock_expired")
    return {"status":"hold" if errors else "ready_for_review", "confirmed_revision":confirmed["revision"] if confirmed else None, "confirmed_expires_at":confirmed["expires_at"] if confirmed else None, "pending_request_ids":sorted(pending), "unique_events":len(events), "errors":sorted(set(errors))}


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


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