#!/usr/bin/env python3
"""Install from a medium that carries its own answers, and check what it skipped.

`install_drive.py` answers all five questions the way a person would. This one
answers only the two that cannot be on a medium, and asserts that the installer
never asked the rest. The difference between the two scripts is the whole
feature: a medium minted with `--host fw12` carries
`/usr/lib/alloy/answers.toml`, and `alloy install` skips every step that file
answers in full.

## What it proves, in order

1. **The wizard opens on step 3 of 6.** The disk and the hostname were on the
   medium, so neither was asked. This is the assertion the feature exists for,
   and it is made against the title the installer draws rather than against a
   log line, because the title is what a person would have seen.
2. **The username arrived filled in** and the password did not.
3. **The review names the prefilled answers and where they came from.** A
   summary that quietly showed answers nobody typed would be worse than asking:
   the screen exists to be checked, and a reader cannot check what it does not
   attribute.
4. **The install completes**, so a skipped step is a step that was genuinely
   answered rather than one that was merely hidden. fw12's recipe encrypts, so
   this is also the first scenario to drive the encrypted path to its end: it
   reads the recovery phrase off the screen and types it back, which is what
   the installer waits for before it will say "finished".

## Why the target is an NVMe

fw12's recipe says `ALLOY_DISK=single-internal-nvme`, and a rule that does not
match falls back to asking, which would look exactly like the feature not
working. `TARGET_BUS=nvme` gives the guest a disk that reports `tran: nvme`, so
the machine under test has the shape the recipe describes. Run it with:

    TARGET_BUS=nvme ./run-vm.sh live

The medium must be minted `--host fw12`, which is also what bakes in the pubkey
this logs in with.

Exits 0 when the install reports success, 1 when an assertion about the screen
fails or the install does, and 3 when the run could not get far enough to have
a verdict.
"""

import argparse
import os
import re
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from install_drive import ENTER, TAB, connect_ssh, wait_for_verdict  # noqa: E402
from tui import Timeout  # noqa: E402

STEP = re.compile(r"step (\d+) of 6")

# The recovery phrase, as `render_recovery` draws it: eight lowercase words on
# their own indented line, in the action color, between the prose above and the
# field below. Anchored on the shape rather than on a line number, because the
# prose around it is the part likely to be reworded.
#
# The bounds are `[^A-Za-z]*` rather than `\s*` because this is read off a
# rendered TUI and every line carries the pane's own border characters. A
# whitespace-anchored pattern finds nothing here, which is a wrong answer that
# looks like a missing phrase.
PHRASE = re.compile(r"^[^A-Za-z]*([a-z]+(?: [a-z]+){7})[^A-Za-z]*$", re.M)

# Where the phrase is, said in the prose above it. Searching after this rather
# than over the whole screen keeps the pattern from matching an unlucky line of
# lowercase prose somewhere else on the page.
PHRASE_AFTER = "is not stored anywhere"


def finish_encrypted(s, timeout):
    """Answer the recovery gate, which only an encrypted install reaches.

    The install is over by the time this screen appears; what it gates is the
    installer saying "finished, reboot". A user who reboots without these words
    has a machine whose disk dies with its TPM, so the installer will not move
    on until they are typed back, and a scenario that stops here would leave the
    encrypted path asserted only as far as the last command.

    `install_drive.py` defaults encryption off and never reaches this, which is
    why this lives here rather than there."""
    s.wait_for(r"Type it back to confirm you have it", timeout)
    s.pump(0.5)
    screen = s.screen.text()
    check("Installation finished" in screen,
          "the recovery screen says the install did not finish", s)
    tail = screen.split(PHRASE_AFTER, 1)[-1]
    hit = PHRASE.search(tail)
    check(hit is not None, "no eight-word recovery phrase on the screen that asks for one", s)
    s.send(hit.group(1))
    s.send(ENTER)


def current_step(s):
    """The step number the title is showing, or None if no title is up."""
    hit = STEP.search(s.screen.text())
    return int(hit.group(1)) if hit else None


def check(condition, message, s):
    """Assert something about the screen, and print the screen when it fails.

    A failed assertion here is a claim about what the installer displayed, so
    the display is the evidence and it goes in the output every time."""
    if condition:
        return
    print(s.screen.text())
    raise SystemExit("error: %s" % message)


def main():
    p = argparse.ArgumentParser(description="install from a medium that answers its own questions")
    p.add_argument("--key", default=os.path.expanduser("~/.ssh/id_ed25519"),
                   help="private key matching the pubkey baked into the medium")
    p.add_argument("--port", type=int, default=2222)
    p.add_argument("--password", default="alloytest")
    p.add_argument("--passphrase", default="alloytestalloytest")
    p.add_argument("--hostname", default="fw12", help="what the recipe baked in")
    p.add_argument("--user", default="max", help="what the recipe baked in")
    p.add_argument("--disk", default="/dev/nvme0n1",
                   help="what the recipe's disk rule should have resolved to")
    p.add_argument("--connect-timeout", type=float, default=300.0)
    p.add_argument("--install-timeout", type=float, default=1800.0)
    args = p.parse_args()

    s = connect_ssh(args.key, args.port, args.connect_timeout)
    try:
        # 1. The headline. Two steps were answered by the medium, so the first
        #    screen a person sees is the third.
        step = current_step(s)
        check(step == 3, "the wizard opened on step %s of 6, so the medium's answers were not "
                         "used; step 1 means the disk rule did not resolve (is TARGET_BUS=nvme "
                         "set?) and the status line says which" % step, s)

        # 2. The account, with the half that can be prefilled already there.
        #    Focus arrives on the password, not on the username, because the
        #    username is answered: see InstallView::focus_first_gap. So this
        #    types the password, tabs once to the confirmation, and submits,
        #    which also leaves the seeded key field alone.
        screen = s.screen.text()
        check(args.user in screen,
              "the account step does not show the username %r the recipe baked in" % args.user, s)
        s.send(args.password)
        s.send(TAB)
        s.send(args.password)
        s.send(ENTER)

        # 3. Encryption. `ALLOY_ENCRYPT=yes` answers the checkbox and never the
        #    passphrase, so this step is shown with one thing left to type and
        #    focus already on it, one slot past the checkbox.
        s.wait_for(r"step 4 of 6", 30)
        s.send(args.passphrase)
        s.send(TAB)
        s.send(args.passphrase)
        s.send(ENTER)

        # 4. The review, which has to say what was decided elsewhere.
        s.wait_for(r"step 5 of 6", 30)
        s.pump(0.5)
        screen = s.screen.text()
        check("from the medium" in screen,
              "the review does not attribute any answer to the medium", s)
        check(args.hostname in screen,
              "the review does not show the baked hostname %r" % args.hostname, s)
        check(args.disk in screen,
              "the review shows a target other than %r, so the disk rule resolved to the wrong "
              "disk" % args.disk, s)
        s.send(ENTER)

        # 5. The credits, which is the step that acts.
        s.wait_for(r"step 6 of 6", 30)
        s.send(ENTER)
        s.wait_for(r"Erase .* and install Alloy", 30)
        s.send(ENTER)

        print("==> installing; this is the long part", flush=True)
        # Encrypted installs stop on the recovery phrase before they will admit
        # to being finished. Answer it, then read the footer as usual.
        finish_encrypted(s, args.install_timeout)
        ok = wait_for_verdict(s, 120)
        print(s.screen.text())
        if not ok:
            print("\nerror: the installer finished without offering a reboot, "
                  "which is how it says the install failed", file=sys.stderr)
            return 1
        return 0
    except Timeout as e:
        print("error: %s" % e, file=sys.stderr)
        return 3
    finally:
        s.close()


if __name__ == "__main__":
    sys.exit(main())
