#!/usr/bin/env python3
"""Send 16 combined A4/A3, color and duplex jobs and record the display.

This is the combined stress test for the three attributes that the queue
actually honours:

  media=A4 / media=A3
  print-color-mode=color / print-color-mode=monochrome
  sides=two-sided-long-edge   (the one-sided jobs send no sides option, which
                                is the measured queue default; short-edge is
                                deliberately not tested because it cannot be
                                told apart from long-edge on the display)

The matrix is 2 paper sizes x 2 duplex modes x 2 color modes = 8 combinations,
submitted twice each, for 16 jobs.  The two copies of each combination are
submitted consecutively.

The queue is assumed to use the server's default Follow-Me.ppd and no
papercut_relay_backend.py mapping wrapper: the options are passed as the IPP
attribute names, which is the path proven to work in the earlier tests.

The source PDF is copied to test01.pdf ... test16.pdf so the CUPS job names
make the tests distinguishable in lpstat and on the release-device display.

All 16 jobs are submitted first.  The script then asks, per job, whether the
display showed the expected paper size, binding and color mode, and writes
``results-stress-combined.txt`` (plain text, safe to upload).

Usage:
    python3 -I combined_option_tests.py
    python3 -I combined_option_tests.py --source a4-cmyk-color-printer-test-page.pdf
    python3 -I combined_option_tests.py --dry-run

Only `lp`, `lpstat` and the source PDF are used.  No relay, JWT or other
credential is involved.
"""

from __future__ import annotations

import argparse
import datetime
import os
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import time

DEFAULT_QUEUE = "Papercut-Default"
DEFAULT_SOURCE = "2324-D.antwWIHM.pdf"
DEFAULT_RESULTS = "results-stress-combined.txt"
DEFAULT_REPEATS = 2

PAPERS = ("A4", "A3")

# (display label, extra lp options).  One-sided sends no sides option: that is
# the measured queue default and the form proven to stay one-sided.
DUPLEX_MODES = (
    ("1-sided", []),
    ("2-sided long edge", ["-o", "sides=two-sided-long-edge"]),
)

COLOR_MODES = (
    ("color", ["-o", "print-color-mode=color"]),
    ("monochrome", ["-o", "print-color-mode=monochrome"]),
)

JOB_RE = re.compile(r"request id is (\S+)")


def build_tests(repeats: int):
    tests = []
    for paper in PAPERS:
        for duplex_label, duplex_options in DUPLEX_MODES:
            for color_label, color_options in COLOR_MODES:
                for repeat in range(1, repeats + 1):
                    options = [
                        "-o", f"media={paper}",
                        *duplex_options,
                        *color_options,
                    ]
                    tests.append({
                        "paper": paper,
                        "duplex": duplex_label,
                        "color": color_label,
                        "repeat": repeat,
                        "options": options,
                        "expected": f"{paper} / {duplex_label} / {color_label}",
                    })
    return tests


def one_line(text: str, limit: int = 300) -> str:
    text = " ".join(text.split())
    return text if len(text) <= limit else text[:limit] + "..."


def validate_source(path: str):
    if not os.path.isfile(path):
        return f"source PDF not found: {path}"
    try:
        with open(path, "rb") as fh:
            head = fh.read(5)
    except OSError as exc:
        return f"cannot read {path}: {exc}"
    if head != b"%PDF-":
        return f"{path} does not start with the %PDF- signature"
    if os.path.getsize(path) == 0:
        return f"{path} is empty"
    return None


def check_queue(queue: str):
    try:
        proc = subprocess.run(
            ["lpstat", "-p", queue],
            capture_output=True, text=True, timeout=30,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        return f"cannot run lpstat: {exc}"
    if proc.returncode != 0:
        detail = one_line((proc.stderr or "") + " " + (proc.stdout or ""))
        return f"lpstat -p {queue} failed: {detail or 'queue not found'}"
    return None


def submit(command) -> tuple[str, str]:
    """Run lp; return (job description, error description)."""
    try:
        proc = subprocess.run(
            command, capture_output=True, text=True, timeout=120,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        return "", f"{type(exc).__name__}: {exc}"
    output = one_line((proc.stdout or "") + " " + (proc.stderr or ""))
    if proc.returncode != 0:
        return "", output or f"lp exited with status {proc.returncode}"
    match = JOB_RE.search(output)
    return (match.group(1) if match else output or "submitted"), ""


def parse_answer(raw: str) -> tuple[str, str]:
    """'y note' -> ('yes', note); 'n note' -> ('no', note); else unknown."""
    text = raw.strip()
    if not text:
        return "not checked", ""
    first, _, rest = text.partition(" ")
    first = first.lower().rstrip(".,:;")
    if first in ("y", "yes"):
        return "yes", rest.strip()
    if first in ("n", "no"):
        return "no", rest.strip()
    return "unknown", text


def ask_all(entries):
    print("\nAll jobs submitted.  Check the release-device display for each")
    print("job (paper size, 1/2-sided, color/mono).")
    print("  y            display matches the expected row")
    print("  n            display does not match")
    print("  y <note>     as y, plus a note")
    print("  <empty>      not checked")
    print("  <other>      kept verbatim as a note, verdict 'unknown'\n")
    for entry in entries:
        if entry["error"]:
            continue
        prompt = (
            f"{entry['name']} ({entry['expected']}, repeat "
            f"{entry['repeat']}) correct? [y/n/note]: "
        )
        try:
            raw = input(prompt)
        except EOFError:
            print()
            entry["verdict"] = "not checked"
            continue
        verdict, note = parse_answer(raw)
        entry["verdict"] = verdict
        entry["note"] = note


def write_results(path, entries, source, queue):
    now = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
    lines = [
        "Combined A4/A3, color and duplex stress test results",
        "",
        f"date:     {now}",
        f"queue:    {queue}",
        f"source:   {source}",
        "PPD:      the server's default Follow-Me.ppd, no mapping backend",
        "matrix:   2 paper sizes x 2 duplex modes x 2 color modes x "
        f"{DEFAULT_REPEATS} repeats = {len(entries)} jobs",
        "methods:  media=A4|A3; print-color-mode=color|monochrome;",
        "          sides=two-sided-long-edge for duplex, nothing for 1-sided",
        "meaning:  verdict 'yes' = the display matched paper/binding/color",
        "",
    ]
    for entry in entries:
        lines.append(f"{entry['name']}: expected {entry['expected']}, "
                     f"repeat {entry['repeat']}")
        lines.append(f"  lp:      {entry['command']}")
        if entry["error"]:
            lines.append(f"  job:     FAILED: {entry['error']}")
        else:
            lines.append(f"  job:     {entry['job']}")
        lines.append(f"  display: {entry['verdict']}")
        lines.append(f"  note:    {entry['note'] or '(none)'}")
        lines.append("")
    lines.append("summary")
    for entry in entries:
        lines.append(
            f"  {entry['name']}  {entry['verdict']:<12} {entry['expected']}"
        )
    counts = {}
    for entry in entries:
        counts[entry["verdict"]] = counts.get(entry["verdict"], 0) + 1
    lines.append("")
    for verdict in ("yes", "no", "unknown", "not checked", "not submitted"):
        if verdict in counts:
            lines.append(f"{verdict}: {counts[verdict]}")
    mismatches = [e["name"] for e in entries if e["verdict"] == "no"]
    lines.append(f"mismatched: {', '.join(mismatches) or '(none)'}")
    lines.append("")
    try:
        with open(path, "w", encoding="utf-8") as fh:
            fh.write("\n".join(lines))
    except OSError as exc:
        print(f"\n[FAIL] cannot write {path}: {exc}", file=sys.stderr)
        return False
    return True


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Send 16 combined paper-size/duplex/color test jobs and "
                    "record the display answers.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    p.add_argument("--queue", default=DEFAULT_QUEUE,
                   help="CUPS queue name for lp -d")
    p.add_argument("--source", default=DEFAULT_SOURCE,
                   help="PDF to print (copied to test01.pdf ... test16.pdf)")
    p.add_argument("--results", default=DEFAULT_RESULTS,
                   help="answers are written here")
    p.add_argument("--repeats", type=int, default=DEFAULT_REPEATS,
                   help="copies of each of the 8 combinations")
    p.add_argument("--delay", type=float, default=1.0,
                   help="seconds between submissions")
    p.add_argument("--force", action="store_true",
                   help="submit even when the lpstat queue preflight fails")
    p.add_argument("--dry-run", action="store_true",
                   help="show the plan and the lp commands without printing")
    return p


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    source = os.path.abspath(args.source)
    results = os.path.abspath(args.results)
    tests = build_tests(max(args.repeats, 1))

    try:
        sys.stdout.reconfigure(line_buffering=True)
    except (AttributeError, ValueError):
        pass

    print("Combined A4/A3, color and duplex stress test")
    print(f"  queue:   {args.queue}")
    print(f"  source:  {source}")
    print(f"  results: {results}")
    print(f"  jobs:    {len(tests)} ({len(tests) // args.repeats} combinations "
          f"x {args.repeats})")
    print("  methods: media=A4|A3, print-color-mode=color|monochrome,")
    print("           sides=two-sided-long-edge (1-sided jobs send no sides)")
    print("  assumes the queue uses the server's default Follow-Me.ppd")
    print("  (no papercut_relay_backend.py option-mapping wrapper)")
    print()
    for index, test in enumerate(tests, 1):
        name = f"test{index:02d}.pdf"
        print(f"  {name}  {test['expected']}  (repeat {test['repeat']})")

    if args.dry_run:
        print("\nDry run: the commands would be")
        for index, test in enumerate(tests, 1):
            command = ["lp", "-d", args.queue, *test["options"],
                       f"test{index:02d}.pdf"]
            print(f"  {shlex.join(command)}")
        print("\n(the job files are copied to test01.pdf ... in a temporary "
              "directory)")
        return 0

    problem = validate_source(source)
    if problem:
        print(f"\n[FAIL] {problem}", file=sys.stderr)
        return 1
    if not args.force:
        problem = check_queue(args.queue)
        if problem:
            print(f"\n[FAIL] {problem}", file=sys.stderr)
            print("Fix the queue name or pass --force to submit anyway.",
                  file=sys.stderr)
            return 1

    workdir = tempfile.mkdtemp(prefix="stress-tests-")
    entries = []
    print(f"\nSubmitting (job copies in {workdir}):\n")
    try:
        for index, test in enumerate(tests, 1):
            name = f"test{index:02d}.pdf"
            job_path = os.path.join(workdir, name)
            try:
                shutil.copyfile(source, job_path)
            except OSError as exc:
                print(f"[{index}/{len(tests)}] {name} {test['expected']}")
                print(f"        cannot copy source: {exc}")
                entries.append({
                    **test, "name": name,
                    "command": shlex.join(
                        ["lp", "-d", args.queue, *test["options"], job_path]),
                    "job": "", "error": f"cannot copy source: {exc}",
                    "verdict": "not submitted", "note": "",
                })
                continue

            command = ["lp", "-d", args.queue, *test["options"], job_path]
            print(f"[{index}/{len(tests)}] {name} {test['expected']}")
            print(f"        {shlex.join(command)}")
            job, error = submit(command)
            if error:
                print(f"        FAILED: {error}")
            else:
                print(f"        {job}")
            entries.append({
                **test, "name": name, "command": shlex.join(command),
                "job": job, "error": error,
                "verdict": "not submitted" if error else "not checked",
                "note": "",
            })
            if index < len(tests) and args.delay > 0:
                time.sleep(args.delay)
    except KeyboardInterrupt:
        print("\nInterrupted during submission; keeping what was sent.")

    submitted = [entry for entry in entries if not entry["error"]]
    if submitted:
        try:
            ask_all(submitted)
        except KeyboardInterrupt:
            print("\nInterrupted; saving the answers collected so far.")

    if not write_results(results, entries, source, args.queue):
        return 1

    print(f"\nAnswers written to {results}")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        print("\nInterrupted.", file=sys.stderr)
        raise SystemExit(130)
