package main

// `snapbeam start`: bind, print the way in, and stay open.

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"syscall"
	"time"
)

// defaultMaxBytes is the largest single item accepted unless --max-size says
// otherwise. Big enough for any screenshot or a short phone video, small
// enough that a mistake does not fill the disk before anyone notices.
const defaultMaxBytes int64 = 256 << 20

// startOptions is everything `start` was asked for, resolved.
type startOptions struct {
	Port        int
	Discovery   int
	Dir         string
	Alias       string
	MaxBytes    int64
	Host        string
	AllowPublic bool
	LocalSend   bool
	NoCode      bool
	QRStyle     string
}

func cmdStart(argv []string) {
	fs := newFlagSet("start")
	port := fs.Int("port", defaultLocalSendPort, "TCP port of the HTTP server")
	fs.IntVar(port, "p", defaultLocalSendPort, "shorthand for --port")
	discovery := fs.Int("discovery-port", defaultLocalSendPort, "UDP port of the multicast group")
	dir := fs.String("dir", "", "folder incoming items are saved in")
	fs.StringVar(dir, "d", "", "shorthand for --dir")
	name := fs.String("name", "", "the name your phone sees")
	fs.StringVar(name, "n", "", "shorthand for --name")
	maxSize := fs.String("max-size", "", "largest single item, e.g. 256MB")
	host := fs.String("host", "", "bind to this address instead of the detected LAN one")
	allowPublic := fs.Bool("allow-public", false, "permit binding to a non-private address")
	noLocalSend := fs.Bool("no-localsend", false, "browser page only")
	noCode := fs.Bool("no-code", false, "run without a pairing code")
	qrStyle := fs.String("qr", "blocks", "QR style: blocks, ascii or invert")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		usageErr("start takes no positional arguments (got %q)", fs.Arg(0))
	}

	opts := startOptions{
		Port:        *port,
		Discovery:   *discovery,
		Dir:         *dir,
		Alias:       strings.TrimSpace(*name),
		MaxBytes:    defaultMaxBytes,
		Host:        strings.TrimSpace(*host),
		AllowPublic: *allowPublic,
		LocalSend:   !*noLocalSend,
		NoCode:      *noCode,
		QRStyle:     *qrStyle,
	}
	if *maxSize != "" {
		n, err := parseSize(*maxSize)
		if err != nil {
			usageErr("%v", err)
		}
		opts.MaxBytes = n
	}
	if opts.Dir == "" {
		opts.Dir = defaultSaveDir()
	}
	if opts.Alias == "" {
		opts.Alias = defaultAlias()
	}
	if opts.Port < 0 || opts.Port > 65535 {
		usageErr("--port must be between 0 and 65535")
	}
	if opts.Discovery < 1 || opts.Discovery > 65535 {
		usageErr("--discovery-port must be between 1 and 65535")
	}
	runStart(opts)
}

// chooseBindAddress decides which address to listen on, and refuses to listen
// on a publicly routable one unless the operator asked for that in so many
// words. This is the single most consequential decision the program makes:
// everything else is guarded by a pairing code, but the choice of interface
// decides who can even knock.
func chooseBindAddress(opts startOptions, candidates []lanCandidate) (net.IP, []lanCandidate, error) {
	if opts.Host != "" {
		ip := net.ParseIP(opts.Host)
		if ip == nil {
			return nil, nil, fmt.Errorf("--host %q is not an IP address", opts.Host)
		}
		if ip.To4() == nil {
			return nil, nil, fmt.Errorf("--host %q is not IPv4; SnapBeam binds IPv4 only", opts.Host)
		}
		if ip.IsUnspecified() && !opts.AllowPublic {
			return nil, nil, fmt.Errorf(
				"--host 0.0.0.0 listens on every interface including any public one.\n" +
					"       Name the LAN address you mean, or pass --allow-public if you really want all of them")
		}
		what, private := classifyIPv4(ip)
		if !private && !ip.IsUnspecified() && !ip.IsLoopback() && !opts.AllowPublic {
			return nil, nil, fmt.Errorf(
				"--host %s is a %s address, not a private LAN one.\n"+
					"       SnapBeam will not listen there without --allow-public", opts.Host, what)
		}
		return ip, candidates, nil
	}

	if len(candidates) == 0 {
		return nil, nil, fmt.Errorf(
			"no usable network interface found.\n" +
				"       SnapBeam needs a LAN address your phone can reach. Check the Wi-Fi is on,\n" +
				"       or name the address yourself with --host")
	}

	private := make([]lanCandidate, 0, len(candidates))
	for _, c := range candidates {
		if c.Private {
			private = append(private, c)
		}
	}
	if len(private) > 0 {
		return private[0].IP, candidates, nil
	}
	if opts.AllowPublic {
		return candidates[0].IP, candidates, nil
	}
	var lines []string
	for _, c := range candidates {
		lines = append(lines, "         "+c.String())
	}
	return nil, nil, fmt.Errorf(
		"none of this machine's addresses is in a private LAN range:\n%s\n"+
			"       SnapBeam will not open a listening socket on a publicly routable address\n"+
			"       by accident. If you know what you are doing, re-run with --allow-public",
		strings.Join(lines, "\n"))
}

func runStart(opts startOptions) {
	if err := os.MkdirAll(opts.Dir, 0o755); err != nil {
		fail("cannot create the save folder %s: %v", opts.Dir, err)
	}
	abs, err := filepath.Abs(opts.Dir)
	if err == nil {
		opts.Dir = abs
	}

	candidates, err := lanCandidates()
	if err != nil {
		fail("%v", err)
	}
	bindIP, candidates, err := chooseBindAddress(opts, candidates)
	if err != nil {
		fail("%v", err)
	}

	rc := newReceiver()
	rc.Alias = opts.Alias
	rc.DeviceModel = deviceModelName()
	rc.DeviceType = "desktop"
	rc.Dir = opts.Dir
	rc.MaxBytes = opts.MaxBytes
	rc.LocalSend = opts.LocalSend
	rc.report = printItem

	if rc.Fingerprint, err = newFingerprint(); err != nil {
		fail("%v", err)
	}
	if !opts.NoCode {
		if rc.PairCode, err = newPairCode(); err != nil {
			fail("%v", err)
		}
	}

	ln, err := net.Listen("tcp4", net.JoinHostPort(bindIP.String(), fmt.Sprint(opts.Port)))
	if err != nil {
		fail("cannot listen on %s port %d: %v", bindIP, opts.Port, err)
	}
	actualPort := ln.Addr().(*net.TCPAddr).Port
	rc.Port = actualPort

	mux := http.NewServeMux()
	rc.registerBrowserRoutes(mux)
	if opts.LocalSend {
		rc.registerLocalSendRoutes(mux)
	}

	srv := &http.Server{
		Handler:           mux,
		ReadHeaderTimeout: 15 * time.Second,
		// No write timeout: a large photo over a weak Wi-Fi signal takes as
		// long as it takes, and cutting it off mid-file would be worse than
		// waiting. Read timeouts on the header alone are enough to shrug off
		// a slow-loris.
		IdleTimeout: 2 * time.Minute,
	}

	stop := make(chan struct{})
	go rc.sweepSessions(stop)

	var disc *discovery
	var joined []string
	var discErr error
	if opts.LocalSend {
		disc = newDiscovery(rc, opts.Discovery)
		disc.Announce = true
		disc.OnPeer = func(p peer) {
			fmt.Printf("%s  saw %q (%s) at %s\n", stamp(), p.Alias, describeDevice(p), p.Address)
		}
		joined, discErr = disc.start()
	}

	printBanner(rc, opts, bindIP, actualPort, candidates, joined, discErr)

	serveErr := make(chan error, 1)
	go func() { serveErr <- srv.Serve(ln) }()

	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
	select {
	case <-sigCh:
		fmt.Printf("\n%s  stopping\n", stamp())
	case err := <-serveErr:
		if err != nil && err != http.ErrServerClosed {
			fmt.Fprintf(stderr, "%s: server error: %v\n", appName, err)
		}
	}

	close(stop)
	if disc != nil {
		disc.stop()
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_ = srv.Shutdown(ctx)

	rc.mu.Lock()
	n := len(rc.received)
	rc.mu.Unlock()
	fmt.Printf("%s  %d item(s) received into %s\n", stamp(), n, rc.Dir)
}

func describeDevice(p peer) string {
	parts := []string{}
	if p.DeviceModel != "" {
		parts = append(parts, p.DeviceModel)
	}
	if p.DeviceType != "" {
		parts = append(parts, p.DeviceType)
	}
	if len(parts) == 0 {
		return "unknown device"
	}
	return strings.Join(parts, " ")
}

// printItem is the one line every arrival produces, whichever door it came
// through. Text is put on the clipboard here, and a clipboard that is missing
// or broken changes the wording rather than the outcome.
func printItem(item savedItem) {
	via := item.Via
	if item.From != "" {
		via += " from " + fmt.Sprintf("%q", item.From)
	}
	fmt.Printf("%s  %-10s %s  (%s, %s)\n", stamp(), "received", item.Path, humanBytes(item.Bytes), item.Kind)
	fmt.Printf("            via %s\n", via)

	if !isTextKind(item.Kind) || item.Bytes > clipboardTextLimit {
		return
	}
	data, err := os.ReadFile(item.Path)
	if err != nil {
		return
	}
	tool, err := copyToClipboard(string(data))
	switch {
	case err != nil && tool == "":
		fmt.Printf("            no clipboard tool on this machine - saved to %s\n", item.Path)
	case err != nil:
		fmt.Printf("            clipboard tool %s failed - saved to %s\n", tool, item.Path)
	default:
		fmt.Printf("            copied to the clipboard with %s\n", tool)
	}
}

// printBanner is what the operator reads once and acts on.
func printBanner(rc *receiver, opts startOptions, bindIP net.IP, port int, candidates []lanCandidate, joined []string, discErr error) {
	url := fmt.Sprintf("http://%s", net.JoinHostPort(bindIP.String(), fmt.Sprint(port)))

	fmt.Println()
	fmt.Printf("  SnapBeam - %s\n", rc.Alias)
	fmt.Println("  Waiting for your phone. Ctrl+C to stop.")
	fmt.Println()
	fmt.Printf("  saving to   %s\n", rc.Dir)
	fmt.Printf("  listening   %s (LAN only)\n", url)
	fmt.Printf("  size limit  %s per item\n", humanBytes(rc.MaxBytes))

	if len(candidates) > 1 {
		fmt.Println()
		fmt.Println("  this machine has more than one address; SnapBeam chose the first:")
		for _, c := range candidates {
			marker := "  "
			if c.IP.Equal(bindIP) {
				marker = "->"
			}
			fmt.Printf("    %s %s\n", marker, c.String())
		}
		fmt.Println("       use --host to pick a different one")
	}

	fmt.Println()
	if opts.LocalSend {
		fmt.Println("  1. WITH THE LOCALSEND APP (nothing to type)")
		if discErr != nil {
			fmt.Printf("     multicast discovery is NOT running: %v\n", discErr)
			fmt.Println("     the app can still reach this computer if you add it by hand:")
			fmt.Printf("     %s port %d\n", bindIP, port)
		} else {
			fmt.Printf("     announcing on %s:%d over %s, HTTP on port %d\n",
				multicastGroup, opts.Discovery, strings.Join(joined, ", "), port)
			fmt.Printf("     open LocalSend on the phone; %q appears in its list.\n", rc.Alias)
			fmt.Println("     screenshot -> Share -> LocalSend -> tap this computer.")
		}
		if rc.PairCode != "" {
			fmt.Println("     it will ask for the PIN below.")
		}
		fmt.Println()
	}

	fmt.Println("  2. WITH NOTHING INSTALLED (point the camera at this)")
	target := url + "/"
	if rc.PairCode != "" {
		// The code travels in the URL FRAGMENT, which a browser never sends
		// to any server, so scanning the code cannot leak it into a log.
		target = url + "/#" + rc.PairCode
	}
	if q, err := encodeQR([]byte(target), ecMedium); err == nil {
		fmt.Println()
		fmt.Print(indent(renderQR(q, opts.QRStyle), "  "))
		fmt.Printf("     %s   (QR version %d, level %s, mask %d)\n", url+"/", q.Version, q.Level, q.Mask)
	} else {
		fmt.Printf("     open %s/ on the phone\n", url)
		fmt.Fprintf(stderr, "%s: could not draw a QR code: %v\n", appName, err)
	}

	fmt.Println()
	if rc.PairCode != "" {
		fmt.Printf("  PAIRING CODE   %s\n", spaced(rc.PairCode))
		fmt.Println("  Type it once on the phone. Three wrong tries block that phone until restart.")
	} else {
		fmt.Println("  NO PAIRING CODE (--no-code): anyone on this network can send to you.")
	}
	fmt.Println()
	fmt.Println("  Plain HTTP on your local network. Nothing is encrypted and nothing leaves it.")
	fmt.Println("  Compatible with LocalSend (Apache-2.0, https://localsend.org) - a separate")
	fmt.Println("  project by its own authors. SnapBeam is not affiliated with it.")
	fmt.Println()
}

// spaced puts air between the digits so the code can be read across a room.
func spaced(code string) string {
	parts := make([]string, 0, len(code))
	for _, r := range code {
		parts = append(parts, string(r))
	}
	return strings.Join(parts, " ")
}

func indent(block, prefix string) string {
	lines := strings.Split(strings.TrimRight(block, "\n"), "\n")
	for i, l := range lines {
		lines[i] = prefix + l
	}
	return strings.Join(lines, "\n") + "\n"
}
