#!/usr/bin/env python3
"""Start the first entry in the queue and answer the machine until it finishes.

This is the smallest complete example of *driving* the instrument rather than just
filling its queue. The loop is short, and it is the same loop the device's own
integration tests use:

    wait until the machine is at a checkpoint
    answer the question it is asking
    repeat

A checkpoint is where the machine stops and waits for a person -- close the lid,
confirm the chip is in, confirm the flow looks right. **Most of those questions
correspond to something physical.** Answering `lidClosed` over the network does not
close the lid; it tells the machine you have closed it. Run this unattended only
against a machine you are simulating.

    python3 kb_run_head.py --host kilobaser.lab.example.org --user apibot --insecure

Usage notes:

* Only the first entry in the queue can be started, so this starts the head. Put
  what you want first there (see kb_queue_assay.py) rather than trying to start it
  out of order.
* When the machine offers two answers at once it is asking a real question, usually
  at the end of a run: take the finished product out and stop, or leave it and roll
  straight into the next queued run. `--on-finish` picks which.
"""

import argparse
import getpass
import sys

from kb_client import Kilobaser, KilobaserError, checkpoint

# Answers that mean "I have done the physical thing". They are safe to send
# automatically only when nothing physical is actually required, i.e. against a
# simulated machine.
FINISH_ANSWERS = {
    "end": "confirmEndProcess",
    "continue": "skipRemoveAndStartNextRun",
}


def main():
    parser = argparse.ArgumentParser(description=__doc__,
                                     formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--host", required=True)
    parser.add_argument("--user", required=True)
    parser.add_argument("--password")
    parser.add_argument("--insecure", action="store_true",
                        help="accept the device's self-signed certificate")
    parser.add_argument("--on-finish", choices=sorted(FINISH_ANSWERS), default="end",
                        help="at the end of a run, stop and collect (end) or start "
                             "the next queued run immediately (continue)")
    parser.add_argument("--timeout", type=int, default=7200,
                        help="give up if the machine is silent this long, in seconds")
    args = parser.parse_args()

    password = args.password or getpass.getpass(f"password for {args.user}: ")
    kb = Kilobaser(args.host, args.user, password, verify=not args.insecure)
    kb.login()

    queue = kb.queue()
    if not queue:
        sys.exit("the queue is empty -- nothing to start")
    head = queue[0]
    print(f"starting {head['title']!r} ({head['id']})")
    print(f"  the machine will run: {' -> '.join(head.get('processTypes') or [])}")
    try:
        kb.start(head)
    except KilobaserError as exc:
        if exc.code == "1-1-8":
            sys.exit("something is already running -- wait for it or cancel it first")
        raise

    drive(kb, head["id"], FINISH_ANSWERS[args.on_finish], args.timeout)


def drive(kb, run_id, finish_answer, timeout):
    """Answer checkpoints until the run we started is no longer the current one.

    `watch()` keeps a mirror of the device up to date from the event stream, so this
    only has to look at the mirror and decide. Note that it re-reads the pending
    answers on every event rather than remembering them: the machine can withdraw a
    question, and answering a question it is no longer asking is an error.
    """
    answered = None
    for _, state in kb.watch():
        status = state["status"] or {}
        pending = checkpoint(state)

        if status.get("currentProcessRunId") not in (run_id, None, ""):
            # The machine moved on to an automatically inserted run -- a chip swap,
            # a cartridge change, a cleaning. Those have checkpoints of their own and
            # must be answered too, so keep going rather than exiting here.
            run_id = status["currentProcessRunId"]
            print(f"  machine moved on to run {run_id}")

        if not pending:
            answered = None
            if status.get("currentProcessRunId") in (None, "") and not state["queue"]:
                print("queue is empty and nothing is running -- done")
                return
            continue

        if state["errors"]:
            for error in state["errors"]:
                print(f"  ! error {error.get('code')}: {error.get('message')}")
            print("  refusing to answer while an error is active -- clear it first")
            return

        # Two pending answers is the machine asking a question with two outcomes,
        # not two things to confirm. One is a real choice; anything else we confirm.
        choice = finish_answer if finish_answer in pending else pending[0]
        if choice == answered:
            continue  # already sent, waiting for the machine to move on
        print(f"  checkpoint {pending} -> answering {choice!r}")
        try:
            kb.answer(status["currentProcessRunId"], choice)
            answered = choice
        except KilobaserError as exc:
            if exc.code == "2-12-2":
                print(f"    {choice!r} was not accepted: {exc.message}")
                answered = choice  # do not hammer it
            else:
                raise


if __name__ == "__main__":
    main()
