package main

// The browser fallback: a single self-contained page served to a phone that
// has not installed anything.
//
// It exists because the LocalSend path needs the LocalSend app. Someone
// standing at a friend's machine, or on a phone with nothing installed, points
// the camera at the QR code and is sending within seconds. Both paths write
// into the same folder, print the same line, and hand text to the same
// clipboard, because both end at receiver.deliver.

import (
	"bytes"
	"encoding/json"
	"fmt"
	"image"
	"image/color"
	"image/png"
	"io"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	_ "embed"
)

//go:embed phone.html
var phonePage []byte

// browserCodeHeader carries the pairing code. A header rather than a query
// parameter, so the code never appears in a proxy log or a browser history
// entry.
const browserCodeHeader = "X-SnapBeam-Code"

// maxTextBeam caps a text beam. Anything larger is a file, and should be sent
// as one.
const maxTextBeam = 4 << 20

// registerBrowserRoutes attaches the phone page and its two upload endpoints.
func (rc *receiver) registerBrowserRoutes(mux *http.ServeMux) {
	mux.HandleFunc("GET /{$}", rc.handlePage)
	mux.HandleFunc("GET /manifest.webmanifest", rc.handleManifest)
	mux.HandleFunc("GET /apple-touch-icon.png", iconHandler(180))
	mux.HandleFunc("GET /apple-touch-icon-precomposed.png", iconHandler(180))
	mux.HandleFunc("GET /icon-192.png", iconHandler(192))
	mux.HandleFunc("GET /icon-512.png", iconHandler(512))
	mux.HandleFunc("GET /favicon.ico", iconHandler(64))
	mux.HandleFunc("GET /beam/hello", rc.handleHello)
	mux.HandleFunc("POST /beam/text", rc.handleBeamText)
	mux.HandleFunc("POST /beam/file", rc.handleBeamFile)
}

// noStore keeps the phone from caching a page whose pairing code changes every
// run, and keeps the responses out of shared caches on the way.
func noStore(w http.ResponseWriter) {
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.Header().Set("Referrer-Policy", "no-referrer")
}

func (rc *receiver) handlePage(w http.ResponseWriter, r *http.Request) {
	noStore(w)
	// The page is entirely self-contained, which this policy states and
	// enforces: no script, style, image or connection may come from anywhere
	// but this server.
	w.Header().Set("Content-Security-Policy",
		"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'")
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = w.Write(phonePage)
}

func (rc *receiver) handleManifest(w http.ResponseWriter, r *http.Request) {
	noStore(w)
	w.Header().Set("Content-Type", "application/manifest+json; charset=utf-8")
	manifest := map[string]any{
		"name":             "SnapBeam - " + rc.Alias,
		"short_name":       "SnapBeam",
		"description":      "Send a screenshot or a snippet of text from this phone to " + rc.Alias + ".",
		"start_url":        "/",
		"scope":            "/",
		"display":          "standalone",
		"orientation":      "portrait",
		"background_color": "#0b1220",
		"theme_color":      "#0b1220",
		"icons": []map[string]string{
			{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
			{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
			{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"},
		},
	}
	_ = json.NewEncoder(w).Encode(manifest)
}

// requireCode enforces the pairing code on a browser request, in constant
// time, and returns false having already written the response when it fails.
func (rc *receiver) requireCode(w http.ResponseWriter, r *http.Request) bool {
	ip := clientIP(r)
	rc.mu.Lock()
	fails := rc.pinFails[ip]
	rc.mu.Unlock()
	if fails >= maxPINAttempts {
		http.Error(w, "too many wrong pairing codes from this address", http.StatusTooManyRequests)
		return false
	}
	supplied := r.Header.Get(browserCodeHeader)
	if !checkPairCode(rc.PairCode, supplied) {
		rc.mu.Lock()
		rc.pinFails[ip]++
		rc.mu.Unlock()
		http.Error(w, "pairing code required", http.StatusUnauthorized)
		return false
	}
	rc.mu.Lock()
	delete(rc.pinFails, ip)
	rc.mu.Unlock()
	return true
}

func (rc *receiver) handleHello(w http.ResponseWriter, r *http.Request) {
	noStore(w)
	if !rc.requireCode(w, r) {
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"name":      rc.Alias,
		"folder":    rc.Dir,
		"maxBytes":  rc.MaxBytes,
		"localsend": rc.LocalSend,
	})
}

// handleBeamText takes the raw request body as text.
func (rc *receiver) handleBeamText(w http.ResponseWriter, r *http.Request) {
	noStore(w)
	if !rc.requireCode(w, r) {
		return
	}
	body, err := readAllLimited(r.Body, maxTextBeam+1)
	if err != nil {
		http.Error(w, "could not read the text", http.StatusBadRequest)
		return
	}
	if len(body) > maxTextBeam {
		http.Error(w, "that text is larger than "+humanBytes(maxTextBeam)+" - send it as a file",
			http.StatusRequestEntityTooLarge)
		return
	}
	if len(bytes.TrimSpace(body)) == 0 {
		http.Error(w, "nothing to send", http.StatusBadRequest)
		return
	}

	item, err := saveStream(rc.Dir, "note.txt", bytes.NewReader(body), rc.MaxBytes, time.Now())
	if err != nil {
		rc.failBeam(w, err)
		return
	}
	item.Via = "browser"
	item.From = clientIP(r)
	item.Kind = "text/plain"

	clip := rc.clip(string(body), item)
	rc.deliver(item)
	writeJSON(w, http.StatusOK, map[string]any{
		"name":      item.Name,
		"bytes":     item.Bytes,
		"clipboard": clip,
	})
}

// handleBeamFile takes the raw request body as a file. The name arrives
// percent-encoded in a header because a header value cannot hold arbitrary
// Unicode, and is sanitised on the way to disk regardless.
func (rc *receiver) handleBeamFile(w http.ResponseWriter, r *http.Request) {
	noStore(w)
	if !rc.requireCode(w, r) {
		return
	}
	name := r.Header.Get("X-SnapBeam-Name")
	if decoded, err := url.QueryUnescape(name); err == nil {
		name = decoded
	}
	if strings.TrimSpace(name) == "" {
		name = "beam" + extensionFor(r.Header.Get("Content-Type"))
	}

	item, err := saveStream(rc.Dir, name, r.Body, rc.MaxBytes, time.Now())
	if err != nil {
		rc.failBeam(w, err)
		return
	}
	item.Via = "browser"
	item.From = clientIP(r)
	rc.deliver(item)
	writeJSON(w, http.StatusOK, map[string]any{"name": item.Name, "bytes": item.Bytes})
}

func (rc *receiver) failBeam(w http.ResponseWriter, err error) {
	if isTooLarge(err) {
		http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
		return
	}
	fmt.Fprintf(stderr, "%s: %v\n", appName, err)
	http.Error(w, "could not write the file", http.StatusInternalServerError)
}

// clip puts short text on the system clipboard, and never lets a missing
// clipboard tool turn a completed transfer into a failure.
func (rc *receiver) clip(text string, item savedItem) bool {
	if len(text) > clipboardTextLimit || !isTextKind(item.Kind) {
		return false
	}
	if _, err := copyToClipboard(text); err != nil {
		return false
	}
	return true
}

// ---------------------------------------------------------------------------
// Icons
// ---------------------------------------------------------------------------

// The home-screen icon is drawn here rather than embedded as a binary blob, so
// the whole program stays readable text: a dark full-bleed square (which is
// what a maskable icon needs) with the same upward beam as the page header.
var (
	iconCache   = map[int][]byte{}
	iconCacheMu sync.Mutex
)

func iconHandler(size int) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		iconCacheMu.Lock()
		data, ok := iconCache[size]
		if !ok {
			data = drawIcon(size)
			iconCache[size] = data
		}
		iconCacheMu.Unlock()
		w.Header().Set("Content-Type", "image/png")
		w.Header().Set("Cache-Control", "public, max-age=86400")
		_, _ = w.Write(data)
	}
}

func drawIcon(size int) []byte {
	bg := color.RGBA{0x0b, 0x12, 0x20, 0xff}
	fg := color.RGBA{0x5e, 0xb2, 0xff, 0xff}
	img := image.NewRGBA(image.Rect(0, 0, size, size))
	for y := 0; y < size; y++ {
		for x := 0; x < size; x++ {
			img.Set(x, y, bg)
		}
	}
	// A maskable icon may be cropped to a circle, so the glyph stays inside
	// the middle 60% of the square.
	f := float64(size)
	cx := f / 2
	headTop := f * 0.24
	headBottom := f * 0.52
	headHalf := f * 0.21
	shaftHalf := f * 0.085
	shaftBottom := f * 0.78

	for y := 0; y < size; y++ {
		fy := float64(y) + 0.5
		for x := 0; x < size; x++ {
			fx := float64(x) + 0.5
			inHead := fy >= headTop && fy <= headBottom &&
				absF(fx-cx) <= headHalf*(fy-headTop)/(headBottom-headTop)
			inShaft := fy > headBottom && fy <= shaftBottom && absF(fx-cx) <= shaftHalf
			if inHead || inShaft {
				img.Set(x, y, fg)
			}
		}
	}
	var buf bytes.Buffer
	if err := png.Encode(&buf, img); err != nil {
		return nil
	}
	return buf.Bytes()
}

func absF(v float64) float64 {
	if v < 0 {
		return -v
	}
	return v
}

// readAllLimited reads at most limit bytes, so a hostile client cannot make
// the receiver allocate without bound.
func readAllLimited(r io.Reader, limit int64) ([]byte, error) {
	return io.ReadAll(io.LimitReader(r, limit))
}
