package main

import (
	"bytes"
	"crypto/rand"
	"crypto/tls"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"html"
	"io"
	"log"
	"mime"
	"net"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"gopkg.in/mail.v2"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/credentials"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/gin-gonic/gin"
	"github.com/joho/godotenv"
	_ "github.com/lib/pq"
)

const (
	MaxUploadSize      = 3 * 1024 * 1024 * 1024 // 3 GB per file
	MaxRequestBodySize = 10 * MaxUploadSize     // hard cap on a whole multi-file batch, to bound disk/memory use while parsing the request
)

var (
	db       *sql.DB
	s3Client *s3.S3
	s3Bucket = "linearbucket1"
)

// FileInfo struct definition
type FileInfo struct {
	ID                int
	FileID            string
	Filename          string
	FileSize          int64
	FormattedFileSize string
	UploadTime        time.Time
	ExpiryTime        time.Time
	UserIP            string
	DownloadLink      string
}

// AdminUploadRow is the per-row shape rendered on the admin dashboard.
type AdminUploadRow struct {
	ID                int
	Filename          string
	FormattedFileSize string
	UploadTime        time.Time
	ExpiryTime        time.Time
	UserIP            string
	Country           string
	DownloadLink      string
}

var (
	ipCountryCache   = make(map[string]string)
	ipCountryCacheMu sync.RWMutex
)

// rateLimiter is a simple per-key sliding-window limiter: at most `limit`
// calls to allow() per `window` for a given key (here, client IP).
type rateLimiter struct {
	mu       sync.Mutex
	requests map[string][]time.Time
	limit    int
	window   time.Duration
}

func newRateLimiter(limit int, window time.Duration) *rateLimiter {
	rl := &rateLimiter{requests: make(map[string][]time.Time), limit: limit, window: window}
	go rl.cleanupLoop()
	return rl
}

func (rl *rateLimiter) allow(key string) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()

	cutoff := time.Now().Add(-rl.window)
	times := rl.requests[key]
	kept := times[:0]
	for _, t := range times {
		if t.After(cutoff) {
			kept = append(kept, t)
		}
	}

	if len(kept) >= rl.limit {
		rl.requests[key] = kept
		return false
	}
	rl.requests[key] = append(kept, time.Now())
	return true
}

// cleanupLoop periodically drops IPs with no requests inside the current
// window, so the map doesn't grow forever over the life of the process.
func (rl *rateLimiter) cleanupLoop() {
	ticker := time.NewTicker(rl.window)
	defer ticker.Stop()
	for range ticker.C {
		rl.mu.Lock()
		cutoff := time.Now().Add(-rl.window)
		for key, times := range rl.requests {
			kept := times[:0]
			for _, t := range times {
				if t.After(cutoff) {
					kept = append(kept, t)
				}
			}
			if len(kept) == 0 {
				delete(rl.requests, key)
			} else {
				rl.requests[key] = kept
			}
		}
		rl.mu.Unlock()
	}
}

func rateLimitMiddleware(rl *rateLimiter) gin.HandlerFunc {
	return func(c *gin.Context) {
		if !rl.allow(c.ClientIP()) {
			c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many requests, please try again later."})
			return
		}
		c.Next()
	}
}

func isPrivateOrLoopbackIP(ipStr string) bool {
	ip := net.ParseIP(ipStr)
	if ip == nil {
		return true
	}
	return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified()
}

type ipAPIBatchQuery struct {
	Query string `json:"query"`
}

type ipAPIBatchResult struct {
	Status  string `json:"status"`
	Country string `json:"country"`
	Query   string `json:"query"`
}

// resolveCountriesForIPs maps each IP to a country name, using an in-memory
// cache and batching uncached lookups through ip-api.com (up to 100 per call).
func resolveCountriesForIPs(ips []string) map[string]string {
	result := make(map[string]string)
	seen := make(map[string]bool)
	var toLookup []string

	for _, ip := range ips {
		if ip == "" || seen[ip] {
			continue
		}
		seen[ip] = true

		if isPrivateOrLoopbackIP(ip) {
			result[ip] = "Local"
			continue
		}

		ipCountryCacheMu.RLock()
		cached, ok := ipCountryCache[ip]
		ipCountryCacheMu.RUnlock()
		if ok {
			result[ip] = cached
			continue
		}
		toLookup = append(toLookup, ip)
	}

	const batchSize = 100
	for start := 0; start < len(toLookup); start += batchSize {
		end := start + batchSize
		if end > len(toLookup) {
			end = len(toLookup)
		}
		chunk := toLookup[start:end]

		reqBody := make([]ipAPIBatchQuery, len(chunk))
		for i, ip := range chunk {
			reqBody[i] = ipAPIBatchQuery{Query: ip}
		}
		bodyBytes, err := json.Marshal(reqBody)
		if err != nil {
			log.Printf("Error marshaling IP batch request: %v", err)
			for _, ip := range chunk {
				result[ip] = "Unknown"
			}
			continue
		}

		resp, err := http.Post("http://ip-api.com/batch?fields=status,country,query", "application/json", bytes.NewReader(bodyBytes))
		if err != nil {
			log.Printf("Error calling ip-api.com: %v", err)
			for _, ip := range chunk {
				result[ip] = "Unknown"
			}
			continue
		}

		var apiResults []ipAPIBatchResult
		decodeErr := json.NewDecoder(resp.Body).Decode(&apiResults)
		resp.Body.Close()
		if decodeErr != nil {
			log.Printf("Error decoding ip-api.com response: %v", decodeErr)
			for _, ip := range chunk {
				result[ip] = "Unknown"
			}
			continue
		}

		for _, r := range apiResults {
			country := "Unknown"
			if r.Status == "success" && r.Country != "" {
				country = r.Country
			}
			result[r.Query] = country
			ipCountryCacheMu.Lock()
			ipCountryCache[r.Query] = country
			ipCountryCacheMu.Unlock()
		}
	}

	return result
}

func main() {
	// Load environment variables from .env file
	if err := godotenv.Load(); err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	// Check if all necessary environment variables are set
	requiredEnvVars := []string{"DB_USER", "DB_PASSWORD", "DB_NAME", "DB_HOST", "DB_PORT", "AWS_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "RECAPTCHA_SITE_KEY", "ADMIN_USERNAME", "ADMIN_PASSWORD"}
	for _, envVar := range requiredEnvVars {
		if os.Getenv(envVar) == "" {
			log.Fatalf("Environment variable %s is not set", envVar)
		}
	}

	// Initialize the database connection
	connStr := fmt.Sprintf(
		"user=%s password=%s dbname=%s host=%s port=%s sslmode=disable",
		os.Getenv("DB_USER"),
		os.Getenv("DB_PASSWORD"),
		os.Getenv("DB_NAME"),
		os.Getenv("DB_HOST"),
		os.Getenv("DB_PORT"),
	)
	var err error
	db, err = sql.Open("postgres", connStr)
	if err != nil {
		log.Fatalf("Error connecting to the database: %v", err)
	}
	defer db.Close()

	// Configure database connection pooling
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(25)
	db.SetConnMaxLifetime(time.Hour)

	// Create the table if it doesn't exist
	createTableQuery := `
		CREATE TABLE IF NOT EXISTS uploads (
			id SERIAL PRIMARY KEY,
			file_id VARCHAR(32) NOT NULL,
			filename TEXT NOT NULL,
			file_size BIGINT NOT NULL,
			upload_time TIMESTAMP NOT NULL,
			expiry_time TIMESTAMP NOT NULL,
			user_ip TEXT,
			formatted_file_size TEXT,
			short_id VARCHAR(16) UNIQUE NOT NULL
		);
	`
	if _, err = db.Exec(createTableQuery); err != nil {
		log.Fatalf("Error creating table: %v", err)
	}

	// Initialize AWS session with environment variables
	sess := session.Must(session.NewSession(&aws.Config{
		Region:      aws.String(os.Getenv("AWS_REGION")),
		Credentials: credentials.NewStaticCredentials(os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), ""),
	}))
	s3Client = s3.New(sess)

	// Start a goroutine to periodically clean up expired files
	go cleanupExpiredFiles()

	// Initialize Gin router
	router := gin.Default()
	// nginx runs on this same host as a reverse proxy (TLS termination) and sets
	// X-Forwarded-For for every request - trust only that loopback peer so its
	// forwarded header is honored, while a request that reaches the app directly
	// (bypassing nginx) can't spoof its IP via the same header.
	router.SetTrustedProxies([]string{"127.0.0.1", "::1"})
	router.LoadHTMLGlob("templates/*.html")
	router.Static("/assets", "./assets")
	router.StaticFile("/robots.txt", "./robots.txt")
	router.StaticFile("/sitemap.xml", "./sitemap.xml")
	router.StaticFile("/llms.txt", "./llms.txt")

	// Use security headers middleware
	router.Use(secureHeadersMiddleware)

	// Define routes
	router.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "home.html", nil)
	})

	// The upload experience now lives on the homepage; redirect old links/bookmarks there.
	router.GET("/upload", func(c *gin.Context) {
		c.Redirect(http.StatusMovedPermanently, "/")
	})

	router.GET("/contact", func(c *gin.Context) {
		c.HTML(http.StatusOK, "contact.html", contactView(nil, nil, "", false))
	})

	router.GET("/privacy", func(c *gin.Context) {
		c.HTML(http.StatusOK, "privacy.html", nil)
	})

	router.GET("/terms", func(c *gin.Context) {
		c.HTML(http.StatusOK, "Terms.html", nil)
	})

	router.POST("/upload", uploadFile)
	router.GET("/d/:id", downloadFile)

	// Each call can send several emails, so keep this tighter than a typical form.
	shareLimiter := newRateLimiter(10, 15*time.Minute)
	router.POST("/share-email", rateLimitMiddleware(shareLimiter), shareViaEmail)

	// A handful of submissions per IP per window is plenty for a real visitor;
	// this is on top of reCAPTCHA to also blunt spam from anyone replaying a
	// valid token.
	contactLimiter := newRateLimiter(5, 15*time.Minute)
	router.POST("/contact", rateLimitMiddleware(contactLimiter), handleContactForm)

	// Admin-only routes, protected with HTTP Basic Auth
	adminAuth := gin.BasicAuth(gin.Accounts{
		os.Getenv("ADMIN_USERNAME"): os.Getenv("ADMIN_PASSWORD"),
	})
	// Rate-limited ahead of the auth check itself, so repeated failed
	// credential guesses count against the limit, not just successful ones.
	adminLimiter := newRateLimiter(30, 15*time.Minute)
	router.GET("/admin", rateLimitMiddleware(adminLimiter), adminAuth, adminDashboard)
	router.GET("/uploads", adminAuth, listUploads)

	// Use Gin's built-in recovery middleware to handle panics
	router.Use(gin.Recovery())

	// Run the server
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	if err := router.Run(":" + port); err != nil {
		log.Fatalf("Error starting server: %v", err)
	}
}

// Secure headers middleware
func secureHeadersMiddleware(c *gin.Context) {
	c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' https://www.googletagmanager.com https://www.google.com https://www.gstatic.com; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; frame-src https://www.google.com; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://www.googletagmanager.com https://stats.g.doubleclick.net https://www.google.com; img-src 'self' data: https://www.google.com https://www.google.co.in https://www.gstatic.com https://linearbucket1.s3.ap-south-1.amazonaws.com; media-src 'self' https://linearbucket1.s3.ap-south-1.amazonaws.com;")
	c.Header("X-Content-Type-Options", "nosniff")
	c.Header("X-Frame-Options", "DENY")
	c.Header("X-XSS-Protection", "1; mode=block")
	c.Header("Cross-Origin-Opener-Policy", "same-origin")
	// Safe because certbot's renewal timer is confirmed active (dry-run succeeds) - browsers
	// ignore this header entirely when a response arrives over plain HTTP, so it only takes
	// effect for the HTTPS traffic nginx already terminates.
	c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
	c.Next()
}

// Generate a random string
func generateRandomString(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

// Helper function to format file size
func formatFileSize(size int64) string {
	const (
		KB = 1024
		MB = KB * 1024
		GB = MB * 1024
	)

	switch {
	case size >= GB:
		return fmt.Sprintf("%.2f GB", float64(size)/GB)
	case size >= MB:
		return fmt.Sprintf("%.2f MB", float64(size)/MB)
	case size >= KB:
		return fmt.Sprintf("%.2f KB", float64(size)/KB)
	default:
		return fmt.Sprintf("%d bytes", size)
	}
}

// isSafeForInlineDisposition reports whether a sniffed content type can only be
// played/displayed by a browser's native media decoder (never executed as script or
// markup), making it safe to serve without forcing a download. SVG is deliberately
// excluded despite the "image/" prefix - unlike raster formats, it can embed <script>.
func isSafeForInlineDisposition(contentType string) bool {
	if contentType == "image/svg+xml" {
		return false
	}
	return strings.HasPrefix(contentType, "image/") ||
		strings.HasPrefix(contentType, "video/") ||
		strings.HasPrefix(contentType, "audio/")
}

// previewKindForFilename classifies a filename by extension for the download page to
// decide whether to render an inline <video>/<audio>/<img> preview. This only affects
// which HTML element to attempt - it has no bearing on whether the underlying S3
// object is actually served inline (isSafeForInlineDisposition decides that at upload
// time), so a mismatched or absent extension just fails to preview, not a security issue.
func previewKindForFilename(filename string) string {
	lower := strings.ToLower(filename)
	for _, ext := range []string{".mp4", ".webm", ".mov", ".m4v", ".avi", ".mkv"} {
		if strings.HasSuffix(lower, ext) {
			return "video"
		}
	}
	for _, ext := range []string{".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"} {
		if strings.HasSuffix(lower, ext) {
			return "audio"
		}
	}
	for _, ext := range []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico"} {
		if strings.HasSuffix(lower, ext) {
			return "image"
		}
	}
	return ""
}

func uploadFile(c *gin.Context) {
	// Cap the request body up front so a client can't force the server to buffer
	// an unbounded amount of data to memory/disk before the per-file size check runs.
	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxRequestBodySize)

	form, err := c.MultipartForm()
	if err != nil {
		log.Printf("Failed to get multipart form: %v", err)
		c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get multipart form"})
		return
	}

	files := form.File["files"]
	if len(files) == 0 {
		log.Printf("No files uploaded")
		c.JSON(http.StatusBadRequest, gin.H{"error": "No files uploaded"})
		return
	}

	var totalSize int64
	for _, file := range files {
		totalSize += file.Size
	}
	log.Printf("Total size of uploaded files: %d bytes", totalSize)

	for _, file := range files {
		if file.Size > MaxUploadSize { // 3 GB limit
			log.Printf("File size exceeds the 3 GB limit: %s", file.Filename)
			c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": fmt.Sprintf("File %s exceeds the 3 GB limit", file.Filename)})
			return
		}
	}

	var links []map[string]string
	for _, file := range files {
		log.Printf("Processing file: %s", file.Filename)

		// Generate a unique identifier for the file and short_id for download link
		fileID, err := generateRandomString(16)
		if err != nil {
			log.Printf("Failed to generate file ID: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate file ID"})
			return
		}

		// Open the file
		f, err := file.Open()
		if err != nil {
			log.Printf("Failed to open file: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file"})
			return
		}
		defer f.Close()

		// Read a few bytes to determine the content type. An empty file legitimately
		// hits io.EOF here with n == 0 - that's not a failure, just an empty read.
		buf := make([]byte, 512)
		n, err := f.Read(buf)
		if err != nil && err != io.EOF {
			log.Printf("Failed to read file for content type detection: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file for content type detection"})
			return
		}
		f.Seek(0, 0) // Reset file pointer to beginning

		contentType := http.DetectContentType(buf[:n])

		// Prefix with fileID so two uploads with the same filename don't collide
		// in S3 and overwrite each other, which used to silently break old download links.
		s3Key := fileID + "/" + file.Filename

		// Force downloads to save as an attachment rather than render inline, EXCEPT
		// for media types a browser can only play/display via its native decoder
		// (images, video, audio) - those can't execute script when rendered, so the
		// download page can preview them inline. Everything else (especially
		// .html/.svg, which CAN carry a <script>) stays forced to attachment: without
		// that, opening its link runs any embedded script in the browser - turning
		// uploads into anonymous phishing/malware hosting under a legitimate-looking
		// linearfiles.com link.
		disposition := "attachment"
		if isSafeForInlineDisposition(contentType) {
			disposition = "inline"
		}
		contentDisposition := mime.FormatMediaType(disposition, map[string]string{"filename": file.Filename})
		if contentDisposition == "" {
			contentDisposition = disposition
		}

		// Upload the file to S3
		_, err = s3Client.PutObject(&s3.PutObjectInput{
			Bucket:             aws.String(s3Bucket),
			Key:                aws.String(s3Key),
			Body:               f,
			ContentType:        aws.String(contentType),
			ContentDisposition: aws.String(contentDisposition),
		})
		if err != nil {
			log.Printf("Failed to upload file to S3: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload file to S3"})
			return
		}

		// Store the upload data in the database
		// Always store in UTC explicitly rather than time.Now()'s ambient server-local
		// zone. expiry_time is a "timestamp without time zone" column, so the digits
		// stored are whatever wall clock we hand it - pinning that to UTC (and reading
		// it back as UTC in downloadFile/cleanupExpiredFiles) makes the 4-day window
		// correct regardless of the server OS's configured timezone, rather than
		// silently depending on it matching Postgres's own session timezone.
		uploadTime := time.Now().UTC()
		expiryTime := uploadTime.Add(4 * 24 * time.Hour) // links are valid for 4 days
		fileSize := file.Size
		formattedFileSize := formatFileSize(fileSize)
		userIP := c.ClientIP()

		shortID, err := generateRandomString(8) // Generate a short ID (you can modify length as needed)
		if err != nil {
			log.Printf("Failed to generate short ID: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate short ID"})
			return
		}

		// Insert into the uploads table, retrying if there's a short_id collision
		for {
			_, err = db.Exec("INSERT INTO uploads (file_id, filename, file_size, upload_time, expiry_time, user_ip, formatted_file_size, short_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", fileID, file.Filename, fileSize, uploadTime, expiryTime, userIP, formattedFileSize, shortID)
			if err != nil {
				if strings.Contains(err.Error(), "duplicate key value violates unique constraint") {
					// Handle short_id collision, generate a new short_id
					log.Printf("Short ID collision, generating a new one")
					shortID, err = generateRandomString(8)
					if err != nil {
						log.Printf("Failed to generate unique short ID: %v", err)
						c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate unique short ID"})
						return
					}
					continue
				} else {
					log.Printf("Failed to save file info to database: %v", err)
					c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file info to database"})
					return
				}
			}
			break
		}

		// Generate the download link using the short ID
		downloadLink := fmt.Sprintf("%s/d/%s", appBaseURL(), shortID)

		links = append(links, map[string]string{
			"id":       shortID,
			"filename": file.Filename,
			"size":     formattedFileSize,
			"link":     downloadLink,
		})
	}

	// Determine the success message based on the number of files
	successMessage := "Files have been successfully uploaded."
	if len(files) == 1 {
		successMessage = "File has been successfully uploaded."
	}

	c.JSON(http.StatusOK, gin.H{
		"message": successMessage,
		"links":   links,
	})
}

func downloadFile(c *gin.Context) {
	shortID := c.Param("id") // Get the short_id from the URL

	var fileID, filename string
	var uploadTime, expiryTime time.Time
	var fileSize int64
	var formattedFileSize string

	// Fetch the file using short_id instead of file_id
	if err := db.QueryRow("SELECT file_id, filename, upload_time, expiry_time, file_size, formatted_file_size FROM uploads WHERE short_id = $1", shortID).Scan(&fileID, &filename, &uploadTime, &expiryTime, &fileSize, &formattedFileSize); err != nil {
		if err == sql.ErrNoRows {
			c.HTML(http.StatusNotFound, "download.html", gin.H{"NotFound": true})
		} else {
			log.Printf("Error retrieving file info: %v", err)
			c.HTML(http.StatusInternalServerError, "download.html", gin.H{"NotFound": true})
		}
		return
	}

	// expiry_time is a "timestamp without time zone" column, and it's always written
	// as explicit UTC digits (see uploadFile) - lib/pq scans it back tagged as UTC by
	// default, which already matches, but reinterpret explicitly rather than relying
	// on that default. This makes the comparison correct regardless of the server
	// OS's configured timezone, instead of depending on it happening to agree with
	// whatever zone the value was originally written in.
	expiryTime = time.Date(expiryTime.Year(), expiryTime.Month(), expiryTime.Day(), expiryTime.Hour(), expiryTime.Minute(), expiryTime.Second(), expiryTime.Nanosecond(), time.UTC)

	// Check if the file has expired
	if time.Now().UTC().After(expiryTime) {
		c.HTML(http.StatusGone, "download.html", gin.H{"Expired": true})
		if _, err := db.Exec("DELETE FROM uploads WHERE short_id = $1", shortID); err != nil { // Optionally delete the expired entry
			log.Printf("Error deleting expired file info: %v", err)
		}
		return
	}

	// Generate a pre-signed URL for the file
	req, _ := s3Client.GetObjectRequest(&s3.GetObjectInput{
		Bucket: aws.String(s3Bucket),
		Key:    aws.String(fileID + "/" + filename),
	})
	url, err := req.Presign(4 * 24 * time.Hour) // pre-signed URL valid for the same 4-day window as the link itself
	if err != nil {
		log.Printf("Failed to generate download link: %v", err)
		c.HTML(http.StatusInternalServerError, "download.html", gin.H{"NotFound": true})
		return
	}

	// Render a real HTML landing page (with Open Graph tags) rather than redirecting
	// straight to the binary. A raw redirect into a large binary is what link-preview
	// crawlers (WhatsApp, iMessage, etc.) choke on - they expect an HTML document with
	// og: tags, not a multi-MB/GB file, and links shared that way were showing up as
	// plain unclickable text instead of a tappable link on the receiving end.
	c.HTML(http.StatusOK, "download.html", gin.H{
		"Filename":          filename,
		"FormattedFileSize": formattedFileSize,
		"DownloadURL":       url,
		"PageURL":           appBaseURL() + "/d/" + shortID,
		"PreviewKind":       previewKindForFilename(filename),
	})
}

// Periodically clean up expired files
func cleanupExpiredFiles() {
	for {
		func() {
			defer func() {
				if r := recover(); r != nil {
					log.Printf("Recovered from panic in cleanupExpiredFiles: %v", r)
				}
			}()
			time.Sleep(1 * time.Hour)
			now := time.Now().UTC() // expiry_time is always written as UTC (see uploadFile)
			rows, err := db.Query("SELECT file_id, filename FROM uploads WHERE expiry_time <= $1", now)
			if err != nil {
				log.Printf("Error querying expired files: %v", err)
				return
			}
			defer rows.Close()
			var fileID, filename string
			for rows.Next() {
				if err := rows.Scan(&fileID, &filename); err != nil {
					log.Printf("Error scanning expired file: %v", err)
					continue
				}
				// Delete the file from S3
				if _, err = s3Client.DeleteObject(&s3.DeleteObjectInput{
					Bucket: aws.String(s3Bucket),
					Key:    aws.String(fileID + "/" + filename),
				}); err != nil {
					log.Printf("Error deleting file %s: %v", filename, err)
				}
			}
		}()
	}
}

func listUploads(c *gin.Context) {
	rows, err := db.Query("SELECT id, file_id, filename, file_size, upload_time, expiry_time, user_ip, formatted_file_size FROM uploads")
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Error querying database: " + err.Error()})
		return
	}
	defer rows.Close()

	uploads := []FileInfo{}
	for rows.Next() {
		var upload FileInfo
		if err := rows.Scan(&upload.ID, &upload.FileID, &upload.Filename, &upload.FileSize, &upload.UploadTime, &upload.ExpiryTime, &upload.UserIP, &upload.FormattedFileSize); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Error scanning row: " + err.Error()})
			return
		}
		upload.FormattedFileSize = formatFileSize(upload.FileSize) // Ensure formatted size is always up-to-date
		uploads = append(uploads, upload)
	}

	if err := rows.Err(); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Error with rows: " + err.Error()})
		return
	}

	c.JSON(http.StatusOK, uploads)
}

const adminPageSize = 25

func adminDashboard(c *gin.Context) {
	yearFilter, _ := strconv.Atoi(c.Query("year"))
	monthFilter, _ := strconv.Atoi(c.Query("month"))
	page, _ := strconv.Atoi(c.Query("page"))
	if page < 1 {
		page = 1
	}

	var whereClauses []string
	var args []interface{}
	argIdx := 1

	if yearFilter > 0 {
		whereClauses = append(whereClauses, fmt.Sprintf("EXTRACT(YEAR FROM upload_time) = $%d", argIdx))
		args = append(args, yearFilter)
		argIdx++
	}
	if monthFilter > 0 {
		whereClauses = append(whereClauses, fmt.Sprintf("EXTRACT(MONTH FROM upload_time) = $%d", argIdx))
		args = append(args, monthFilter)
		argIdx++
	}

	whereSQL := ""
	if len(whereClauses) > 0 {
		whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
	}

	var total int
	countQuery := "SELECT COUNT(*) FROM uploads " + whereSQL
	if err := db.QueryRow(countQuery, args...).Scan(&total); err != nil {
		c.HTML(http.StatusInternalServerError, "admin.html", gin.H{"Error": "Error counting uploads: " + err.Error()})
		return
	}

	totalPages := (total + adminPageSize - 1) / adminPageSize
	if totalPages < 1 {
		totalPages = 1
	}
	if page > totalPages {
		page = totalPages
	}
	offset := (page - 1) * adminPageSize

	query := fmt.Sprintf(
		"SELECT id, filename, file_size, upload_time, expiry_time, user_ip, formatted_file_size, short_id FROM uploads %s ORDER BY upload_time DESC LIMIT $%d OFFSET $%d",
		whereSQL, argIdx, argIdx+1,
	)
	queryArgs := append(args, adminPageSize, offset)

	rows, err := db.Query(query, queryArgs...)
	if err != nil {
		c.HTML(http.StatusInternalServerError, "admin.html", gin.H{"Error": "Error querying database: " + err.Error()})
		return
	}
	defer rows.Close()

	type scannedRow struct {
		row     AdminUploadRow
		shortID string
	}
	var scanned []scannedRow
	var ips []string

	for rows.Next() {
		var r AdminUploadRow
		var shortID string
		var fileSize int64
		if err := rows.Scan(&r.ID, &r.Filename, &fileSize, &r.UploadTime, &r.ExpiryTime, &r.UserIP, &r.FormattedFileSize, &shortID); err != nil {
			c.HTML(http.StatusInternalServerError, "admin.html", gin.H{"Error": "Error scanning row: " + err.Error()})
			return
		}
		r.DownloadLink = "/d/" + shortID
		scanned = append(scanned, scannedRow{row: r, shortID: shortID})
		ips = append(ips, r.UserIP)
	}

	if err := rows.Err(); err != nil {
		c.HTML(http.StatusInternalServerError, "admin.html", gin.H{"Error": "Error with rows: " + err.Error()})
		return
	}

	countries := resolveCountriesForIPs(ips)
	uploads := make([]AdminUploadRow, 0, len(scanned))
	for _, s := range scanned {
		row := s.row
		row.Country = countries[row.UserIP]
		if row.Country == "" {
			row.Country = "Unknown"
		}
		uploads = append(uploads, row)
	}

	// Years available for the filter dropdown
	yearRows, err := db.Query("SELECT DISTINCT EXTRACT(YEAR FROM upload_time)::int AS yr FROM uploads ORDER BY yr DESC")
	var availableYears []int
	if err == nil {
		for yearRows.Next() {
			var yr int
			if yearRows.Scan(&yr) == nil {
				availableYears = append(availableYears, yr)
			}
		}
		yearRows.Close()
	}

	months := []gin.H{
		{"Value": 1, "Label": "January"}, {"Value": 2, "Label": "February"}, {"Value": 3, "Label": "March"},
		{"Value": 4, "Label": "April"}, {"Value": 5, "Label": "May"}, {"Value": 6, "Label": "June"},
		{"Value": 7, "Label": "July"}, {"Value": 8, "Label": "August"}, {"Value": 9, "Label": "September"},
		{"Value": 10, "Label": "October"}, {"Value": 11, "Label": "November"}, {"Value": 12, "Label": "December"},
	}

	c.HTML(http.StatusOK, "admin.html", gin.H{
		"Uploads":        uploads,
		"Total":          total,
		"Page":           page,
		"TotalPages":     totalPages,
		"HasPrev":        page > 1,
		"HasNext":        page < totalPages,
		"PrevPage":       page - 1,
		"NextPage":       page + 1,
		"Year":           yearFilter,
		"Month":          monthFilter,
		"AvailableYears": availableYears,
		"Months":         months,
	})
}

// contactView builds the data the contact template needs; the maps are always
// non-nil so the template can index them safely.
func contactView(values, errs map[string]string, message string, success bool) gin.H {
	if values == nil {
		values = map[string]string{}
	}
	if errs == nil {
		errs = map[string]string{}
	}
	return gin.H{
		"RecaptchaSiteKey": os.Getenv("RECAPTCHA_SITE_KEY"),
		"Values":           values,
		"Errors":           errs,
		"Message":          message,
		"Success":          success,
	}
}

func handleContactForm(c *gin.Context) {
	// Honeypot: real visitors never see or fill this field, bots usually do.
	// Pretend success so bots get no signal.
	if strings.TrimSpace(c.PostForm("website")) != "" {
		c.HTML(http.StatusOK, "contact.html", contactView(nil, nil, "Thanks! Your message has been sent.", true))
		return
	}

	values := map[string]string{
		"name":    cleanText(c.PostForm("name"), 200, false),
		"email":   cleanText(c.PostForm("email"), 320, false),
		"subject": cleanText(c.PostForm("subject"), 300, false),
		"message": cleanText(c.PostForm("message"), 5000, true),
	}
	captcha := c.PostForm("g-recaptcha-response")
	errs := map[string]string{}

	if n := len([]rune(values["name"])); n < 2 {
		errs["name"] = "Please enter your name (at least 2 characters)."
	} else if n > 80 {
		errs["name"] = "Name must be 80 characters or fewer."
	}
	if values["email"] == "" {
		errs["email"] = "Please enter your email address."
	} else if len(values["email"]) > 254 || !emailPattern.MatchString(values["email"]) {
		errs["email"] = "Please enter a valid email address."
	}
	if n := len([]rune(values["subject"])); n < 3 {
		errs["subject"] = "Please enter a subject (at least 3 characters)."
	} else if n > 120 {
		errs["subject"] = "Subject must be 120 characters or fewer."
	}
	if n := len([]rune(values["message"])); n < 10 {
		errs["message"] = "Please write a message (at least 10 characters)."
	} else if n > 2000 {
		errs["message"] = "Message must be 2000 characters or fewer."
	}
	if captcha == "" {
		errs["captcha"] = "Please confirm you are not a robot."
	}

	if len(errs) > 0 {
		c.HTML(http.StatusBadRequest, "contact.html", contactView(values, errs, "Please fix the highlighted fields and try again.", false))
		return
	}

	if !verifyCaptcha(captcha) {
		errs["captcha"] = "reCAPTCHA verification failed. Please try again."
		c.HTML(http.StatusBadRequest, "contact.html", contactView(values, errs, "reCAPTCHA verification failed. Please try again.", false))
		return
	}

	body := fmt.Sprintf("Name: %s\nEmail: %s\nSubject: %s\n\n%s", values["name"], values["email"], values["subject"], values["message"])

	m := mail.NewMessage()
	m.SetHeader("From", os.Getenv("SMTP_EMAIL"))
	m.SetHeader("Reply-To", values["email"])
	m.SetHeader("To", os.Getenv("SMTP_EMAIL"))
	m.SetHeader("Subject", values["subject"])
	m.SetBody("text/plain", body)

	port := 465
	if p, err := strconv.Atoi(os.Getenv("SMTP_PORT")); err == nil && p > 0 {
		port = p
	}
	d := mail.NewDialer(os.Getenv("SMTP_HOST"), port, os.Getenv("SMTP_EMAIL"), os.Getenv("SMTP_PASSWORD"))
	d.TLSConfig = &tls.Config{InsecureSkipVerify: true}

	if err := d.DialAndSend(m); err != nil {
		log.Printf("Failed to send contact email: %v", err)
		c.HTML(http.StatusInternalServerError, "contact.html", contactView(values, nil, "We couldn't send your message right now. Please try again in a few minutes.", false))
		return
	}

	c.HTML(http.StatusOK, "contact.html", contactView(nil, nil, "Thanks! Your message has been sent. We'll get back to you soon.", true))
}

// appBaseURL is the public origin used in generated download links. Set APP_BASE_URL
// (e.g. http://localhost:8080) to make links work in local development.
func appBaseURL() string {
	if v := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"); v != "" {
		return v
	}
	return "https://linearfiles.com"
}

var (
	shortIDPattern = regexp.MustCompile(`^[0-9a-f]{16}$`)
	emailPattern   = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
	splitPattern   = regexp.MustCompile(`[,;\s]+`)
)

const (
	maxShareRecipients = 5
	maxShareFiles      = 20
)

// cleanText drops control characters (keeping newlines when allowed), trims, and caps
// the length, so user-supplied text can't inject headers or bloat the email.
func cleanText(s string, maxRunes int, allowNewlines bool) string {
	var b strings.Builder
	for _, r := range s {
		if r == '\n' && allowNewlines {
			b.WriteRune(r)
		} else if r == '\t' || r == '\r' {
			b.WriteRune(' ')
		} else if r >= 0x20 && r != 0x7f {
			b.WriteRune(r)
		}
	}
	out := strings.TrimSpace(b.String())
	if r := []rune(out); len(r) > maxRunes {
		out = string(r[:maxRunes])
	}
	return out
}

type shareEmailRequest struct {
	IDs        []string `json:"ids"`
	Recipients string   `json:"recipients"`
	SenderName string   `json:"sender_name"`
	Message    string   `json:"message"`
}

// shareViaEmail emails the download link(s) of just-uploaded files. Only links that
// exist in the uploads table and haven't expired can be sent, so the endpoint can't be
// used to send arbitrary content.
func shareViaEmail(c *gin.Context) {
	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64*1024)
	var req shareEmailRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request."})
		return
	}

	if len(req.IDs) == 0 || len(req.IDs) > maxShareFiles {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Nothing to share."})
		return
	}

	var recipients []string
	seen := make(map[string]bool)
	for _, r := range splitPattern.Split(strings.TrimSpace(req.Recipients), -1) {
		if r == "" {
			continue
		}
		if len(r) > 254 || !emailPattern.MatchString(r) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Please enter valid email addresses."})
			return
		}
		key := strings.ToLower(r)
		if !seen[key] {
			seen[key] = true
			recipients = append(recipients, r)
		}
	}
	if len(recipients) == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Please enter at least one email address."})
		return
	}
	if len(recipients) > maxShareRecipients {
		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("You can email up to %d people at once.", maxShareRecipients)})
		return
	}

	if os.Getenv("SMTP_EMAIL") == "" || os.Getenv("SMTP_HOST") == "" || os.Getenv("SMTP_PASSWORD") == "" {
		log.Printf("Share email requested but SMTP is not configured")
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Email sharing is not available right now."})
		return
	}

	type sharedFile struct{ Name, Size, Link string }
	var files []sharedFile
	for _, id := range req.IDs {
		if !shortIDPattern.MatchString(id) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file link."})
			return
		}
		var filename, size string
		var expiry time.Time
		err := db.QueryRow("SELECT filename, formatted_file_size, expiry_time FROM uploads WHERE short_id = $1", id).Scan(&filename, &size, &expiry)
		if err == sql.ErrNoRows {
			c.JSON(http.StatusNotFound, gin.H{"error": "This link no longer exists or has expired."})
			return
		} else if err != nil {
			log.Printf("Error retrieving file info for sharing: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong. Please try again."})
			return
		}
		expiry = time.Date(expiry.Year(), expiry.Month(), expiry.Day(), expiry.Hour(), expiry.Minute(), expiry.Second(), 0, time.UTC)
		if time.Now().UTC().After(expiry) {
			c.JSON(http.StatusGone, gin.H{"error": "This link has expired."})
			return
		}
		files = append(files, sharedFile{Name: filename, Size: size, Link: appBaseURL() + "/d/" + id})
	}

	senderName := cleanText(req.SenderName, 60, false)
	note := cleanText(req.Message, 500, true)

	subject := "You've received files via LinearFiles"
	intro := "Someone has shared files with you via LinearFiles."
	if senderName != "" {
		subject = senderName + " shared files with you via LinearFiles"
		intro = senderName + " has shared files with you via LinearFiles."
	}

	var text, htmlBody strings.Builder
	text.WriteString(intro + "\n\n")
	htmlBody.WriteString(`<div style="font-family:Arial,Helvetica,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#0b1020">`)
	htmlBody.WriteString(`<h2 style="margin:0 0 12px;font-size:20px">` + html.EscapeString(intro) + `</h2>`)
	if note != "" {
		text.WriteString("Message:\n" + note + "\n\n")
		htmlBody.WriteString(`<p style="margin:0 0 16px;padding:12px 14px;background:#fff8e1;border-radius:8px;white-space:pre-wrap">` + html.EscapeString(note) + `</p>`)
	}
	for _, f := range files {
		text.WriteString(fmt.Sprintf("%s (%s)\n%s\n\n", f.Name, f.Size, f.Link))
		htmlBody.WriteString(`<p style="margin:0 0 14px"><strong>` + html.EscapeString(f.Name) + `</strong> <span style="color:#6b7280">(` + html.EscapeString(f.Size) + `)</span><br>` +
			`<a href="` + html.EscapeString(f.Link) + `" style="display:inline-block;margin-top:6px;padding:10px 18px;background:#ffc20e;color:#0b1020;text-decoration:none;border-radius:8px;font-weight:bold">Download</a></p>`)
	}
	text.WriteString("Links expire 4 days after upload.\n")
	htmlBody.WriteString(`<p style="margin:20px 0 0;color:#6b7280;font-size:13px">Links expire 4 days after upload. No account needed to download.</p></div>`)

	var msgs []*mail.Message
	for _, r := range recipients {
		m := mail.NewMessage()
		m.SetHeader("From", os.Getenv("SMTP_EMAIL"))
		m.SetHeader("To", r)
		m.SetHeader("Subject", subject)
		m.SetBody("text/plain", text.String())
		m.AddAlternative("text/html", htmlBody.String())
		msgs = append(msgs, m)
	}

	port := 465
	if p, err := strconv.Atoi(os.Getenv("SMTP_PORT")); err == nil && p > 0 {
		port = p
	}
	d := mail.NewDialer(os.Getenv("SMTP_HOST"), port, os.Getenv("SMTP_EMAIL"), os.Getenv("SMTP_PASSWORD"))
	d.TLSConfig = &tls.Config{InsecureSkipVerify: true}

	if err := d.DialAndSend(msgs...); err != nil {
		log.Printf("Failed to send share email: %v", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "We couldn't send the email. Please try again later."})
		return
	}

	noun := "person"
	if len(recipients) > 1 {
		noun = "people"
	}
	c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Email sent to %d %s.", len(recipients), noun)})
}

// Function to verify CAPTCHA response
func verifyCaptcha(captcha string) bool {
	secret := os.Getenv("RECAPTCHA_SECRET_KEY")
	resp, err := http.PostForm("https://www.google.com/recaptcha/api/siteverify", url.Values{"secret": {secret}, "response": {captcha}})
	if err != nil {
		log.Printf("Failed to verify CAPTCHA: %v", err)
		return false
	}
	defer resp.Body.Close()

	var result struct {
		Success    bool     `json:"success"`
		ErrorCodes []string `json:"error-codes"`
	}

	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		log.Printf("Failed to parse CAPTCHA response: %v", err)
		return false
	}

	if result.Success {
		return true
	}

	log.Printf("CAPTCHA verification failed: %+v", result)
	return false
}
