// Runs cabletest for a fixed time and dumps the panel to png, since the machine
// with the display on it is not the machine looking at it.
//
//	sudo go run ./harness -for 20s -at 3s,15s -- -a enp1s0f0np0 -b enp1s0f1np1
package main

import (
	"flag"
	"fmt"
	"image/png"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strings"
	"syscall"
	"time"
)

const (
	shotsDir = "shots"
	grace    = 3 * time.Second
)

func main() {
	runFor := flag.Duration("for", 15*time.Second, "how long to let cabletest run")
	at := flag.String("at", "", "offsets to capture the panel at, comma separated, e.g. 3s,10s")
	flag.Parse()

	if err := run(*runFor, *at, flag.Args()); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

func parseShots(s string, runFor time.Duration) ([]time.Duration, error) {
	if s == "" {
		return nil, nil
	}
	var out []time.Duration
	for _, f := range strings.Split(s, ",") {
		d, err := time.ParseDuration(strings.TrimSpace(f))
		if err != nil {
			return nil, fmt.Errorf("bad offset %q: %w", f, err)
		}
		if d < 0 || d >= runFor {
			return nil, fmt.Errorf("offset %s falls outside the %s run", d, runFor)
		}
		out = append(out, d)
	}
	slices.Sort(out)
	return out, nil
}

func run(runFor time.Duration, at string, args []string) error {
	if os.Geteuid() != 0 {
		return fmt.Errorf("needs root for the raw sockets and the framebuffer: sudo go run ./harness")
	}
	shots, err := parseShots(at, runFor)
	if err != nil {
		return err
	}
	if len(shots) > 0 {
		if err := os.MkdirAll(shotsDir, 0o755); err != nil {
			return err
		}
	}

	cmd := exec.Command("go", append([]string{"run", "."}, args...)...)
	cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
	// Its own process group: go run's compiled child is reparented rather than
	// killed when go run dies, and an orphan holding the wire poisons every
	// measurement after it.
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	if err := cmd.Start(); err != nil {
		return err
	}
	pgid := cmd.Process.Pid

	done := make(chan error, 1)
	go func() { done <- cmd.Wait() }()

	start := time.Now()
	for _, d := range shots {
		select {
		case err := <-done:
			return exited(start, err)
		case <-time.After(time.Until(start.Add(d))):
		}
		name := filepath.Join(shotsDir, d.String()+".png")
		if err := shoot(name); err != nil {
			stop(pgid, done)
			return err
		}
		fmt.Printf("captured %s\n", name)
	}

	select {
	case err := <-done:
		return exited(start, err)
	case <-time.After(time.Until(start.Add(runFor))):
	}
	return stop(pgid, done)
}

func exited(start time.Time, err error) error {
	ran := time.Since(start).Round(time.Millisecond)
	if err != nil {
		return fmt.Errorf("cabletest exited after %s: %w", ran, err)
	}
	return fmt.Errorf("cabletest exited after %s", ran)
}

func stop(pgid int, done chan error) error {
	syscall.Kill(-pgid, syscall.SIGINT)
	select {
	case <-done:
	case <-time.After(grace):
		syscall.Kill(-pgid, syscall.SIGKILL)
		<-done
	}
	// Reaping go run says nothing about what it left behind, so the group itself
	// is what gets checked.
	if groupGone(pgid) {
		return nil
	}
	syscall.Kill(-pgid, syscall.SIGKILL)
	if groupGone(pgid) {
		return nil
	}
	return fmt.Errorf("process group %d survived SIGKILL; check with pgrep -a cabletest", pgid)
}

func groupGone(pgid int) bool {
	deadline := time.Now().Add(grace)
	for {
		if syscall.Kill(-pgid, 0) == syscall.ESRCH {
			return true
		}
		if time.Now().After(deadline) {
			return false
		}
		time.Sleep(50 * time.Millisecond)
	}
}

func shoot(name string) error {
	g, err := openGrabber()
	if err != nil {
		return err
	}
	defer g.close()

	img, err := g.frame()
	if err != nil {
		return err
	}

	out, err := os.Create(name)
	if err != nil {
		return err
	}
	if err := png.Encode(out, img); err != nil {
		out.Close()
		return err
	}
	return out.Close()
}
