package main

import (
	"database/sql"
	"fmt"
	"image"
	"image/gif"
	"image/jpeg"
	_ "image/png"
	"io"
	"math/rand/v2"
	"mime/multipart"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	xdraw "golang.org/x/image/draw"
)

type Entry struct {
	PID          int64  `json:"pid"`
	UID          int64  `json:"uid"`
	CreatedAt    int64  `json:"created_at"`
	Content      string `json:"content"`
	Filepath     string `json:"filepath"`
	ReplyTo      int64  `json:"reply_to"`
	ReplyCount   int64  `json:"reply_count"`
	LastActivity int64  `json:"last_activity"`
	Deleted      int64  `json:"deleted"`
	Username     string `json:"username"`
	Avatar       string `json:"avatar"`
}

// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen.
const entrySelect = `
	SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
	       e.reply_to, e.reply_count, e.last_activity, e.deleted,
	       COALESCE(u.username, ''), COALESCE(u.avatar, '')
	FROM entry e LEFT JOIN user u ON u.uid = e.uid`

// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
func scanEntries(rows *sql.Rows) ([]Entry, error) {
	defer rows.Close()
	entries := []Entry{}
	for rows.Next() {
		var e Entry
		if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
			&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar); err != nil {
			return nil, err
		}
		entries = append(entries, e)
	}
	return entries, rows.Err()
}

// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
func entryByPID(pid int64) (Entry, error) {
	var e Entry
	err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
		&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
		&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar)
	return e, err
}

// feedPage liefert eine Feed-Seite (20 Beiträge). Ist uid != 0, nur die Beiträge
// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
// sonst werden alle Beiträge chronologisch geliefert.
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
	if page < 0 {
		page = 0
	}
	if page > 100 {
		page = 100
	}
	const count = 20

	query := entrySelect
	args := []any{}
	where := []string{}
	if uid != 0 {
		where = append(where, "e.uid = ?")
		args = append(args, uid)
	}
	if rootsOnly {
		where = append(where, "e.reply_to = 0")
	}
	if len(where) > 0 {
		query += " WHERE " + strings.Join(where, " AND ")
	}
	if rootsOnly {
		query += ` ORDER BY e.last_activity DESC`
	} else {
		query += ` ORDER BY e.created_at DESC`
	}
	query += ` LIMIT ? OFFSET ?`
	args = append(args, count, page*count)

	rows, err := db.Query(query, args...)
	if err != nil {
		return nil, err
	}
	return scanEntries(rows)
}

func handleFeed(w http.ResponseWriter, r *http.Request) {
	page, _ := strconv.Atoi(chi.URLParam(r, "page"))
	entries, err := feedPage(page, 0, true)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Datenbankfehler")
		return
	}
	writeJSON(w, http.StatusOK, entries)
}

// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
func handleUserFeed(w http.ResponseWriter, r *http.Request) {
	var uid int64
	if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
		writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
		return
	}

	page, _ := strconv.Atoi(chi.URLParam(r, "page"))
	entries, err := feedPage(page, uid, false)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Datenbankfehler")
		return
	}
	writeJSON(w, http.StatusOK, entries)
}

// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
// dann über /entry/{pid}/thread.
func serveEntryPage(w http.ResponseWriter, r *http.Request) {
	http.ServeFile(w, r, "web/entry.html")
}

// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
// direkten Antworten (öffentlich).
func handleThread(w http.ResponseWriter, r *http.Request) {
	pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "Ungültige pid")
		return
	}

	entry, err := entryByPID(pid)
	if err != nil {
		writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
		return
	}

	// Ahnenkette von Eltern bis Root hochlaufen, dann umdrehen (Root zuerst).
	ancestors := []Entry{}
	for cur := entry.ReplyTo; cur != 0 && len(ancestors) < 50; {
		a, err := entryByPID(cur)
		if err != nil {
			break
		}
		ancestors = append(ancestors, a)
		cur = a.ReplyTo
	}
	for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 {
		ancestors[i], ancestors[j] = ancestors[j], ancestors[i]
	}

	rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Datenbankfehler")
		return
	}
	replies, err := scanEntries(rows)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Datenbankfehler")
		return
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"entry":     entry,
		"ancestors": ancestors,
		"replies":   replies,
	})
}

func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
	uid := uidFromContext(r.Context())

	// MaxBytesReader kappt den gesamten Request hart: ParseMultipartForm
	// begrenzt nur den Speicher, alles darüber liefe sonst unbegrenzt in
	// Temp-Dateien auf die Platte.
	r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
	if err := r.ParseMultipartForm(16 << 20); err != nil && err != http.ErrNotMultipart {
		writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
		return
	}

	content := strings.TrimSpace(r.FormValue("content"))
	if len(content) > 1000 {
		content = content[:1000]
	}

	replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64)
	if replyTo != 0 {
		if _, err := entryByPID(replyTo); err != nil {
			writeError(w, http.StatusBadRequest, "Elternbeitrag nicht gefunden")
			return
		}
	}

	filepath := ""
	if r.MultipartForm != nil {
		if files := r.MultipartForm.File["file"]; len(files) > 0 {
			if stored, err := storeImage(files[0]); err == nil {
				filepath = stored
			}
		}
	}

	if filepath == "" && content == "" {
		writeError(w, http.StatusBadRequest, "Leerer Beitrag!")
		return
	}

	pid := int64(rand.IntN(999999999999))
	now := time.Now().Unix()

	tx, err := db.Begin()
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
		return
	}
	if _, err := tx.Exec(
		`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
		 VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
		pid, uid, now, content, filepath, replyTo, now,
	); err != nil {
		tx.Rollback()
		writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
		return
	}
	// Antwort: reply_count und last_activity aller Vorfahren bis zum Root anheben.
	for cur, i := replyTo, 0; cur != 0 && i < 50; i++ {
		if _, err := tx.Exec(
			`UPDATE entry SET reply_count = reply_count + 1,
			        last_activity = MAX(COALESCE(last_activity, 0), ?) WHERE pid = ?`,
			now, cur,
		); err != nil {
			tx.Rollback()
			writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
			return
		}
		if err := tx.QueryRow(`SELECT reply_to FROM entry WHERE pid = ?`, cur).Scan(&cur); err != nil {
			break
		}
	}
	if err := tx.Commit(); err != nil {
		writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
		return
	}

	writeJSON(w, http.StatusCreated, map[string]any{
		"pid":      pid,
		"content":  content,
		"filepath": filepath,
		"reply_to": replyTo,
	})
}

// handleEditEntry ändert den Inhalt eines eigenen Beitrags. Das angehängte Bild
// bleibt unverändert; last_activity wird nicht angefasst (eine Korrektur soll den
// Thread nicht nach oben spülen).
func handleEditEntry(w http.ResponseWriter, r *http.Request) {
	pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "Ungültige pid")
		return
	}
	uid := uidFromContext(r.Context())

	var owner int64
	if err := db.QueryRow(`SELECT uid FROM entry WHERE pid = ?`, pid).Scan(&owner); err != nil {
		writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
		return
	}
	if owner != uid {
		writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.")
		return
	}

	content := strings.TrimSpace(r.FormValue("content"))
	if content == "" {
		writeError(w, http.StatusBadRequest, "Inhalt darf nicht leer sein.")
		return
	}
	if len(content) > 1000 {
		content = content[:1000]
	}

	if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
		writeError(w, http.StatusInternalServerError, "Bearbeiten fehlgeschlagen")
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
}

// handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als
// [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild
// und Autor werden entfernt.
func handleDeleteEntry(w http.ResponseWriter, r *http.Request) {
	pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "Ungültige pid")
		return
	}
	uid := uidFromContext(r.Context())

	var owner int64
	var filepath string
	if err := db.QueryRow(`SELECT uid, filepath FROM entry WHERE pid = ?`, pid).Scan(&owner, &filepath); err != nil {
		writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
		return
	}
	if owner != uid {
		writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.")
		return
	}

	if _, err := db.Exec(
		`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid,
	); err != nil {
		writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
		return
	}
	if filepath != "" {
		os.Remove(filepath)
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func voteTally(pid, uid int64) (left, right int, selected string) {
	selected = "none"
	if uid != 0 {
		var cur string
		if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
			selected = cur
		}
	}
	db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left)
	db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right)
	return
}

func writeTally(w http.ResponseWriter, pid, uid int64) {
	left, right, selected := voteTally(pid, uid)
	writeJSON(w, http.StatusOK, map[string]any{
		"pid":      pid,
		"left":     left,
		"right":    right,
		"selected": selected,
	})
}

// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
func handleVotes(w http.ResponseWriter, r *http.Request) {
	pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "Ungültige pid")
		return
	}

	var uid int64
	if s, ok := getSession(r); ok {
		uid = s.UID
	}
	writeTally(w, pid, uid)
}

// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
//   - keine bisherige Stimme -> neue Stimme
//   - gleiche Stimme erneut   -> Stimme zurückziehen (Toggle)
//   - andere Stimme           -> auf den neuen Modus wechseln
func handleVote(w http.ResponseWriter, r *http.Request) {
	pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
	if err != nil {
		writeError(w, http.StatusBadRequest, "Ungültige pid")
		return
	}
	uid := uidFromContext(r.Context())

	mode := r.FormValue("mode")
	if mode != "left" && mode != "right" {
		writeError(w, http.StatusBadRequest, "Ungültiger Modus (left oder right)")
		return
	}

	// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
	if _, err := entryByPID(pid); err != nil {
		writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
		return
	}

	var prev string
	readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
	if readErr != nil && readErr != sql.ErrNoRows {
		writeError(w, http.StatusInternalServerError, "Datenbankfehler")
		return
	}

	var execErr error
	switch {
	case readErr == sql.ErrNoRows:
		_, execErr = db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode)
	case prev == mode:
		_, execErr = db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid)
	default:
		_, execErr = db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid)
	}
	if execErr != nil {
		writeError(w, http.StatusInternalServerError, "Datenbankfehler")
		return
	}

	writeTally(w, pid, uid)
}

const (
	maxImageSize   = 1024        // Zielkante beim Skalieren
	maxImagePixels = 50_000_000  // pro Frame: Schutz vor Decompression-Bomb
	maxGIFBytes    = 16 << 20    // Obergrenze für durchgereichte GIFs
	maxGIFFrames   = 1000        // animiertes GIF: Frame-Budget
	maxGIFPixels   = 100_000_000 // animiertes GIF: Summe aller Frame-Pixel

	// Hartes Limit für Upload-Requests insgesamt (Bild + Formularfelder +
	// Multipart-Overhead), via http.MaxBytesReader.
	maxUploadBytes = 17 << 20
)

// storeImage speichert ein hochgeladenes Bild unter static/media.
// Standbilder (JPEG/PNG/Einzelbild-GIF) werden auf max. 1024px skaliert und
// dabei durch Decode+Re-Encode sanitisiert. Animierte GIFs würden durch das
// Re-Encode auf das erste Frame reduziert -> sie werden stattdessen unverändert
// durchgereicht. Die Bytes sind dann NICHT sanitisiert; deshalb werden Medien
// mit X-Content-Type-Options: nosniff ausgeliefert (siehe routes()), und Frame-
// sowie Gesamtpixel-Budget begrenzen den DoS-Vektor von gif.DecodeAll.
func storeImage(fh *multipart.FileHeader) (string, error) {
	src, err := fh.Open()
	if err != nil {
		return "", err
	}
	defer src.Close()

	// Dimensionen aus dem Header prüfen, bevor wir die vollen Pixel allokieren:
	// eine kleine Datei kann riesige Maße deklarieren (Decompression-Bomb) und
	// sonst beim Decode den Speicher sprengen.
	cfg, format, err := image.DecodeConfig(src)
	if err != nil {
		return "", err
	}
	if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels {
		return "", fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height)
	}

	if err := os.MkdirAll("static/media", 0o755); err != nil {
		return "", err
	}

	if format == "gif" {
		// LimitReader deckelt den von DecodeAll gelesenen (und damit allokierten)
		// Speicher unabhängig von einer evtl. gelogenen Header-Größe.
		if _, err := src.Seek(0, io.SeekStart); err != nil {
			return "", err
		}
		g, err := gif.DecodeAll(io.LimitReader(src, maxGIFBytes))
		if err != nil {
			return "", err
		}
		if len(g.Image) > 1 {
			if len(g.Image) > maxGIFFrames {
				return "", fmt.Errorf("animiertes GIF: zu viele Frames (%d)", len(g.Image))
			}
			total := 0
			for _, fr := range g.Image {
				b := fr.Bounds()
				total += b.Dx() * b.Dy()
				if total > maxGIFPixels {
					return "", fmt.Errorf("animiertes GIF zu groß")
				}
			}
			if _, err := src.Seek(0, io.SeekStart); err != nil {
				return "", err
			}
			return copyUpload(src, "gif")
		}
		// Einzelbild-GIF: weiter in den Skalierungspfad unten.
	}

	if _, err := src.Seek(0, io.SeekStart); err != nil {
		return "", err
	}
	img, _, err := image.Decode(src)
	if err != nil {
		return "", err
	}

	b := img.Bounds()
	width, height := b.Dx(), b.Dy()

	nw, nh := width, height
	if width > maxImageSize || height > maxImageSize {
		if width > height {
			nw, nh = maxImageSize, height*maxImageSize/width
		} else {
			nw, nh = width*maxImageSize/height, maxImageSize
		}
	}

	ext := "jpg"
	if format == "gif" {
		ext = "gif"
	}
	name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)

	dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
	xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)

	out, err := os.Create(name)
	if err != nil {
		return "", err
	}
	defer out.Close()

	if ext == "gif" {
		err = gif.Encode(out, dst, nil)
	} else {
		err = jpeg.Encode(out, dst, &jpeg.Options{Quality: 85})
	}
	if err != nil {
		os.Remove(name)
		return "", err
	}
	return name, nil
}

// copyUpload schreibt die Originalbytes (max. maxGIFBytes) unter einem
// serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname
// fließt nie ein (kein Path-Traversal).
func copyUpload(src io.Reader, ext string) (string, error) {
	name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
	out, err := os.Create(name)
	if err != nil {
		return "", err
	}
	defer out.Close()

	// maxGIFBytes+1 lesen, um ein Überschreiten sicher zu erkennen.
	n, err := io.Copy(out, io.LimitReader(src, maxGIFBytes+1))
	if err != nil {
		os.Remove(name)
		return "", err
	}
	if n > maxGIFBytes {
		os.Remove(name)
		return "", fmt.Errorf("datei zu groß")
	}
	return name, nil
}
