package main

// `snapbeam list`, `snapbeam peers` and `snapbeam send`.
//
// The send side is the mirror of localsend.go: prepare-upload to get a session
// and one token per file, then one upload request per file. It is here because
// the protocol describes both halves and implementing only one leaves you
// unable to test the other, but SnapBeam's job is receiving and that is where
// the care has gone.

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"mime"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"
)

// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------

func cmdList(argv []string) {
	fs := newFlagSet("list")
	dir := fs.String("dir", "", "folder to list")
	fs.StringVar(dir, "d", "", "shorthand for --dir")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *dir == "" && fs.NArg() > 0 {
		*dir = fs.Arg(0)
	}
	if *dir == "" {
		*dir = defaultSaveDir()
	}

	items, err := listSaved(*dir)
	if err != nil {
		if os.IsNotExist(err) {
			fmt.Fprintf(stderr, "%s: nothing has arrived yet - %s does not exist\n", appName, *dir)
			fmt.Fprintf(stderr, "Start the receiver first:\n  %s start\n", appName)
			os.Exit(1)
		}
		fail("%v", err)
	}

	if *asJSON {
		type row struct {
			Name  string `json:"name"`
			Path  string `json:"path"`
			Bytes int64  `json:"bytes"`
			Human string `json:"human"`
			Kind  string `json:"kind"`
			When  string `json:"received"`
		}
		out := make([]row, 0, len(items))
		for _, it := range items {
			out = append(out, row{it.Name, it.Path, it.Bytes, humanBytes(it.Bytes), it.Kind,
				it.When.Format(time.RFC3339)})
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(map[string]any{"folder": *dir, "count": len(out), "items": out}); err != nil {
			fail("%v", err)
		}
		return
	}

	fmt.Printf("folder : %s\n", *dir)
	fmt.Printf("items  : %d (newest first)\n\n", len(items))
	if len(items) == 0 {
		fmt.Println("(nothing has arrived yet)")
		return
	}
	var total int64
	for _, it := range items {
		fmt.Printf("  %s  %10s  %-26s  %s\n",
			it.When.Format("2006-01-02 15:04:05"), humanBytes(it.Bytes), it.Kind, it.Name)
		total += it.Bytes
	}
	fmt.Printf("\n  %s in total\n", humanBytes(total))
}

// ---------------------------------------------------------------------------
// peers
// ---------------------------------------------------------------------------

// cmdPeers is a discovery-only run. It is not merely a UDP listener: the
// protocol's handshake is two-way, and a device that was already running
// answers an announcement by making an HTTP request BACK to the announcer. So
// `peers` opens a throwaway HTTP server on an ephemeral port, announces that
// port, and reports both the devices that announce themselves and the ones
// that answer. Without the server half it would only ever see devices that
// happened to start up during the listening window.
//
// That throwaway server exposes /register and /info only. It has no save
// folder and no upload routes, so nothing can be sent to it.
func cmdPeers(argv []string) {
	fs := newFlagSet("peers")
	seconds := fs.Int("seconds", 5, "how long to listen")
	port := fs.Int("port", defaultLocalSendPort, "multicast port")
	fs.IntVar(port, "p", defaultLocalSendPort, "shorthand for --port")
	allowPublic := fs.Bool("allow-public", false, "permit binding to a non-private address")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *seconds < 1 {
		usageErr("--seconds must be at least 1")
	}

	candidates, err := lanCandidates()
	if err != nil {
		fail("%v", err)
	}
	bindIP, _, err := chooseBindAddress(startOptions{AllowPublic: *allowPublic}, candidates)
	if err != nil {
		fail("%v", err)
	}

	rc := newReceiver()
	rc.Alias = defaultAlias() + " (looking)"
	rc.DeviceModel = deviceModelName()
	rc.DeviceType = "desktop"
	if rc.Fingerprint, err = newFingerprint(); err != nil {
		fail("%v", err)
	}

	// Port 0: the operating system hands back a free one.
	ln, err := net.Listen("tcp4", net.JoinHostPort(bindIP.String(), "0"))
	if err != nil {
		fail("cannot open a temporary port on %s: %v", bindIP, err)
	}
	defer ln.Close()
	rc.Port = ln.Addr().(*net.TCPAddr).Port

	d := newDiscovery(rc, *port)
	d.Announce = true

	var seen int
	var mu sync.Mutex
	show := func(p peer, how string) {
		mu.Lock()
		defer mu.Unlock()
		seen++
		fmt.Printf("  %-24s %-20s %-22s %s\n", trim(p.Alias, 24), describeDevice(p),
			net.JoinHostPort(p.Address, fmt.Sprint(p.Port)), how)
	}
	d.OnPeer = func(p peer) { show(p, "announced") }
	rc.onRegister = func(p peer) {
		if d.record(p) {
			show(p, "answered us")
		}
	}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/localsend/v2/register", rc.handleRegister)
	mux.HandleFunc("GET /api/localsend/v2/info", rc.handleInfo)
	srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
	go func() { _ = srv.Serve(ln) }()

	joined, err := d.start()
	if err != nil {
		fail("%v", err)
	}
	fmt.Printf("listening on %s:%d over %s for %ds\n", multicastGroup, *port, strings.Join(joined, ", "), *seconds)
	fmt.Printf("announcing myself as %q on %s so devices already running answer back\n\n",
		rc.Alias, net.JoinHostPort(bindIP.String(), fmt.Sprint(rc.Port)))
	fmt.Printf("  %-24s %-20s %-22s %s\n", "NAME", "DEVICE", "ADDRESS", "HOW")
	time.Sleep(time.Duration(*seconds) * time.Second)
	d.stop()
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	_ = srv.Shutdown(ctx)

	found := d.knownPeers()
	fmt.Println()
	if len(found) == 0 {
		fmt.Println("no LocalSend devices answered.")
		fmt.Println("that usually means multicast is blocked on this network, or nothing")
		fmt.Println("else is running. A device can still be reached directly with --to.")
		return
	}
	fmt.Printf("%d device(s).\n", len(found))
}

func trim(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n-1] + "…"
}

// ---------------------------------------------------------------------------
// send
// ---------------------------------------------------------------------------

func cmdSend(argv []string) {
	fs := newFlagSet("send")
	to := fs.String("to", "", "device to send to, host or host:port")
	code := fs.String("code", "", "pairing code / PIN the far end asks for")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	paths := fs.Args()
	if len(paths) == 0 {
		usageErr("send needs at least one file")
	}
	if strings.TrimSpace(*to) == "" {
		usageErr("send needs --to <host[:port]>")
	}

	host := *to
	if _, _, err := net.SplitHostPort(host); err != nil {
		host = net.JoinHostPort(host, fmt.Sprint(defaultLocalSendPort))
	}
	base := "http://" + host

	rc := newReceiver()
	rc.Alias = defaultAlias()
	rc.DeviceModel = deviceModelName()
	rc.DeviceType = "desktop"
	rc.Port = defaultLocalSendPort
	fp, err := newFingerprint()
	if err != nil {
		fail("%v", err)
	}
	rc.Fingerprint = fp

	files := map[string]fileDTO{}
	order := make([]string, 0, len(paths))
	byID := map[string]string{}
	for i, p := range paths {
		info, err := os.Stat(p)
		if err != nil {
			fail("cannot read %s: %v", p, err)
		}
		if info.IsDir() {
			fail("%s is a folder; SnapBeam sends files, one or more at a time", p)
		}
		sum, err := fileSHA256(p)
		if err != nil {
			fail("%v", err)
		}
		id := fmt.Sprintf("f%d", i+1)
		files[id] = fileDTO{
			ID:       id,
			FileName: filepath.Base(p),
			Size:     info.Size(),
			FileType: mimeOfPath(p),
			SHA256:   sum,
		}
		byID[id] = p
		order = append(order, id)
	}

	body, err := json.Marshal(prepareUploadRequest{Info: rc.info(), Files: files})
	if err != nil {
		fail("%v", err)
	}
	prepareURL := base + "/api/localsend/v2/prepare-upload"
	if *code != "" {
		prepareURL += "?pin=" + url.QueryEscape(*code)
	}

	client := &http.Client{Timeout: 30 * time.Second}
	resp, err := client.Post(prepareURL, "application/json", bytes.NewReader(body))
	if err != nil {
		fail("cannot reach %s: %v", host, err)
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusOK:
	case http.StatusNoContent:
		fmt.Println("the receiver accepted nothing to transfer.")
		return
	case http.StatusUnauthorized:
		fail("the receiver wants a pairing code - pass --code <digits>")
	case http.StatusForbidden:
		fail("the receiver rejected the transfer: %s", firstLine(resp.Body))
	case http.StatusConflict:
		fail("the receiver is busy with another transfer")
	case http.StatusTooManyRequests:
		fail("the receiver has blocked this machine after too many wrong codes")
	default:
		fail("prepare-upload failed: %s - %s", resp.Status, firstLine(resp.Body))
	}

	var prep prepareUploadResponse
	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&prep); err != nil {
		fail("the receiver sent a reply this program could not read: %v", err)
	}

	sent := 0
	for _, id := range order {
		token, ok := prep.Files[id]
		if !ok {
			fmt.Printf("  skipped   %s (the receiver did not accept it)\n", filepath.Base(byID[id]))
			continue
		}
		if err := uploadOne(client, base, prep.SessionID, id, token, byID[id]); err != nil {
			fmt.Fprintf(stderr, "%s: %v\n", appName, err)
			continue
		}
		fmt.Printf("  sent      %s (%s)\n", filepath.Base(byID[id]), humanBytes(files[id].Size))
		sent++
	}
	fmt.Printf("\n%d of %d file(s) delivered to %s\n", sent, len(order), host)
}

func uploadOne(client *http.Client, base, sessionID, fileID, token, path string) error {
	f, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("cannot read %s: %w", path, err)
	}
	defer f.Close()
	info, err := f.Stat()
	if err != nil {
		return fmt.Errorf("cannot read %s: %w", path, err)
	}

	q := url.Values{}
	q.Set("sessionId", sessionID)
	q.Set("fileId", fileID)
	q.Set("token", token)
	req, err := http.NewRequest(http.MethodPost, base+"/api/localsend/v2/upload?"+q.Encode(), f)
	if err != nil {
		return err
	}
	req.ContentLength = info.Size()
	req.Header.Set("Content-Type", "application/octet-stream")

	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("sending %s: %w", filepath.Base(path), err)
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return fmt.Errorf("sending %s: %s - %s", filepath.Base(path), resp.Status, firstLine(resp.Body))
	}
	return nil
}

func firstLine(r io.Reader) string {
	b, _ := readAllLimited(r, 4<<10)
	s := strings.TrimSpace(string(b))
	if i := strings.IndexByte(s, '\n'); i >= 0 {
		s = s[:i]
	}
	if s == "" {
		return "(no message)"
	}
	return s
}

// mimeOfPath is what goes in the fileType field. Protocol v2 carries a MIME
// type there; v1 carried a small enum, and the reference implementation
// accepts either on the way in.
func mimeOfPath(path string) string {
	if ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))); ct != "" {
		if i := strings.IndexByte(ct, ';'); i >= 0 {
			ct = ct[:i]
		}
		return ct
	}
	return "application/octet-stream"
}

func fileSHA256(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", fmt.Errorf("cannot read %s: %w", path, err)
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", fmt.Errorf("cannot read %s: %w", path, err)
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}
