// Command share-svc is a tiny HTTP service that stores character-sheet JSON
// blobs and hands back short, content-addressed IDs so they can be shared via a
// compact URL (e.g. ...?s=a1b2c3d4) instead of stuffing the whole sheet into the
// query string.
//
// API:
//
//	POST /api/s        body: sheet JSON      -> 200 {"id":"<id>"}
//	GET  /api/s/{id}                          -> 200 sheet JSON
//
// Storage is a single SQLite file; payloads are deflated on the way in and
// inflated on the way out. IDs are derived from the SHA-256 of the JSON, so
// posting the same sheet twice yields the same ID (free dedupe + idempotency).
package main

import (
	"bytes"
	"compress/flate"
	"crypto/sha256"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"os"
	"time"

	_ "modernc.org/sqlite"
)

const (
	// idLen is how many base64url chars of the content hash form an ID.
	// 12 chars ~= 72 bits, far more than enough to avoid collisions here.
	idLen = 12
	// maxBody caps an accepted sheet payload (pre-compression).
	maxBody = 512 << 10 // 512 KiB
)

type store struct {
	db *sql.DB
}

func openStore(path string) (*store, error) {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, err
	}
	// Single writer; WAL keeps reads fast and concurrent.
	if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`); err != nil {
		return nil, err
	}
	if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS shares (
		id      TEXT PRIMARY KEY,
		data    BLOB NOT NULL,
		created INTEGER NOT NULL
	)`); err != nil {
		return nil, err
	}
	return &store{db: db}, nil
}

// put stores raw (uncompressed) JSON bytes and returns its short ID. Storing the
// same bytes again is a no-op that returns the same ID.
func (s *store) put(raw []byte) (string, error) {
	sum := sha256.Sum256(raw)
	id := base64.RawURLEncoding.EncodeToString(sum[:])[:idLen]

	deflated, err := deflate(raw)
	if err != nil {
		return "", err
	}
	// INSERT OR IGNORE: a matching ID already holds identical content.
	if _, err := s.db.Exec(
		`INSERT OR IGNORE INTO shares (id, data, created) VALUES (?, ?, ?)`,
		id, deflated, time.Now().Unix(),
	); err != nil {
		return "", err
	}
	return id, nil
}

// get returns the stored JSON bytes for an ID, or sql.ErrNoRows if unknown.
func (s *store) get(id string) ([]byte, error) {
	var deflated []byte
	err := s.db.QueryRow(`SELECT data FROM shares WHERE id = ?`, id).Scan(&deflated)
	if err != nil {
		return nil, err
	}
	return inflate(deflated)
}

func deflate(b []byte) ([]byte, error) {
	var buf bytes.Buffer
	w, err := flate.NewWriter(&buf, flate.BestCompression)
	if err != nil {
		return nil, err
	}
	if _, err := w.Write(b); err != nil {
		return nil, err
	}
	if err := w.Close(); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

func inflate(b []byte) ([]byte, error) {
	r := flate.NewReader(bytes.NewReader(b))
	defer r.Close()
	return io.ReadAll(r)
}

func main() {
	addr := envOr("ADDR", ":8090")
	dbPath := envOr("DB_PATH", "shares.db")
	corsOrigin := os.Getenv("CORS_ORIGIN") // empty = same-origin only

	st, err := openStore(dbPath)
	if err != nil {
		log.Fatalf("open store: %v", err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/s", func(w http.ResponseWriter, r *http.Request) {
		raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
		if err != nil {
			http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
			return
		}
		if !json.Valid(raw) {
			http.Error(w, "body must be JSON", http.StatusBadRequest)
			return
		}
		id, err := st.put(raw)
		if err != nil {
			log.Printf("put: %v", err)
			http.Error(w, "storage error", http.StatusInternalServerError)
			return
		}
		writeJSON(w, map[string]string{"id": id})
	})
	mux.HandleFunc("GET /api/s/{id}", func(w http.ResponseWriter, r *http.Request) {
		raw, err := st.get(r.PathValue("id"))
		if errors.Is(err, sql.ErrNoRows) {
			http.Error(w, "not found", http.StatusNotFound)
			return
		}
		if err != nil {
			log.Printf("get: %v", err)
			http.Error(w, "storage error", http.StatusInternalServerError)
			return
		}
		// Shares are immutable, so let clients/proxies cache aggressively.
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		w.Header().Set("Content-Type", "application/json")
		w.Write(raw)
	})

	handler := withCORS(corsOrigin, mux)
	srv := &http.Server{
		Addr:              addr,
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Printf("share-svc listening on %s (db=%s)", addr, dbPath)
	log.Fatal(srv.ListenAndServe())
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(v)
}

// withCORS optionally allows a cross-origin frontend (handy when webpack-dev
// runs on a different port than the API). When origin is empty it's a no-op.
func withCORS(origin string, next http.Handler) http.Handler {
	if origin == "" {
		return next
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", origin)
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func envOr(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}
