package web

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
)

// .fab browser download (v0.124.0, decision 3): PORTABILITY, not a backup tier — no scheduling,
// no status surface. The EXISTING async export pipeline produces the bundle (same producer as a
// drive export → byte-identical format); the only new mechanism is the dest (a dedicated staging
// dir under the controller data dir) and a guarded streaming exit. Bundles are removed after a
// successful stream and swept by a TTL cleanup (fabDownloadTTL) on startup and on each start.

const fabDownloadTTL = time.Hour

// fabDownloadRoot is the staging root; the exporter writes bundles into ExportDir(root).
func (s *Server) fabDownloadRoot() string {
	return filepath.Join(s.cfg.Paths.DataDir, "fab-downloads")
}

func (s *Server) fabDownloadDir() string {
	return appexport.ExportDir(s.fabDownloadRoot())
}

// fabDownloadNamePattern matches the exporter's own bundle naming (<stack>_<timestamp>.fab);
// a single safe segment, no separators, cannot start with a dot.
var fabDownloadNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*\.fab$`)

// validFabDownloadName is the containment guard's first half: only a bare, expected-shaped
// bundle FILENAME is accepted — no separators, no traversal, no absolute paths.
func validFabDownloadName(name string) bool {
	if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
		return false
	}
	return fabDownloadNamePattern.MatchString(name)
}

// apiExportDownloadEstimate returns the size estimate for a download export (shown BEFORE
// starting — the pre-download honesty mechanism). GET /api/export/download/estimate?stack=X
func (s *Server) apiExportDownloadEstimate(w http.ResponseWriter, r *http.Request) {
	if s.appExporter == nil {
		jsonError(w, "App export not available", http.StatusServiceUnavailable)
		return
	}
	stackName := r.URL.Query().Get("stack")
	if stackName == "" || !validStackName(stackName) {
		jsonError(w, "Missing or invalid stack parameter", http.StatusBadRequest)
		return
	}
	if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil {
		jsonError(w, err.Error(), http.StatusInternalServerError)
		return
	}
	est, err := s.appExporter.EstimateExport(stackName, s.fabDownloadRoot())
	if err != nil {
		s.logger.Printf("[ERROR] [web] download-export estimate failed for %s: %v", stackName, err)
		jsonError(w, err.Error(), http.StatusInternalServerError)
		return
	}
	jsonResponse(w, map[string]interface{}{"ok": true, "data": est})
}

// apiExportDownloadStart starts the EXISTING async export with the staging dir as dest.
// POST /api/export/download/start {stack_name, password, stop_app}
func (s *Server) apiExportDownloadStart(w http.ResponseWriter, r *http.Request) {
	if s.appExporter == nil {
		jsonError(w, "App export not available", http.StatusServiceUnavailable)
		return
	}
	var req struct {
		StackName string `json:"stack_name"`
		Password  string `json:"password"`
		StopApp   bool   `json:"stop_app"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	if req.StackName == "" || !validStackName(req.StackName) {
		jsonError(w, "Missing or invalid stack_name", http.StatusBadRequest)
		return
	}
	// Opportunistic TTL sweep so an abandoned bundle never outlives fabDownloadTTL by much.
	sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, s.logger)
	if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil {
		jsonError(w, err.Error(), http.StatusInternalServerError)
		return
	}
	// A concurrent export/import gets the exporter's own busy answer (single-flight).
	if err := s.appExporter.StartExport(appexport.ExportRequest{
		StackName: req.StackName,
		DestDrive: s.fabDownloadRoot(),
		Password:  req.Password,
		StopApp:   req.StopApp,
	}); err != nil {
		s.logger.Printf("[ERROR] [web] download-export start failed for %s: %v", req.StackName, err)
		jsonError(w, err.Error(), http.StatusConflict)
		return
	}
	s.logger.Printf("[INFO] [web] download-export started for %s (staging dir)", req.StackName)
	jsonResponse(w, map[string]interface{}{"ok": true})
}

// apiExportDownloadFetch streams a produced bundle to the browser and removes it afterwards.
// GET /api/export/download?file=<basename>. Containment is NON-NEGOTIABLE: only an
// expected-shaped basename, resolved strictly inside the staging exports dir, is ever opened.
func (s *Server) apiExportDownloadFetch(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get("file")
	if !validFabDownloadName(name) {
		s.logger.Printf("[WARN] [web] download-export fetch refused: invalid bundle name")
		jsonError(w, "Invalid bundle name", http.StatusBadRequest)
		return
	}
	dir := filepath.Clean(s.fabDownloadDir())
	path := filepath.Join(dir, name)
	// Second half of the guard: the resolved path's parent must BE the staging dir.
	if filepath.Dir(path) != dir {
		s.logger.Printf("[WARN] [web] download-export fetch refused: path escapes the staging dir")
		jsonError(w, "Invalid bundle name", http.StatusBadRequest)
		return
	}
	f, err := os.Open(path)
	if err != nil {
		jsonError(w, "Bundle not found", http.StatusNotFound)
		return
	}
	defer f.Close()
	fi, err := f.Stat()
	if err != nil || fi.IsDir() {
		jsonError(w, "Bundle not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
	n, err := io.Copy(w, f) // streaming — never ReadAll
	if err != nil {
		// Mid-stream failure (client gone): keep the bundle for a retry; the TTL sweep owns it.
		s.logger.Printf("[WARN] [web] download-export stream aborted after %d bytes: %v", n, err)
		return
	}
	f.Close()
	if rmErr := os.Remove(path); rmErr != nil {
		s.logger.Printf("[WARN] [web] download-export cleanup failed (TTL sweep will retry): %v", rmErr)
	}
	s.logger.Printf("[INFO] [web] download-export streamed %d bytes and removed the staged bundle", n)
}

// sweepFabDownloads removes staged bundles (and exporter temp files) older than maxAge.
// Returns how many entries were removed. Pure function of (dir, now) — tests inject both.
func sweepFabDownloads(dir string, now time.Time, maxAge time.Duration, logger *log.Logger) int {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return 0
	}
	removed := 0
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if !strings.HasSuffix(name, ".fab") && !strings.HasSuffix(name, ".tmp") {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		if now.Sub(info.ModTime()) > maxAge {
			if err := os.Remove(filepath.Join(dir, name)); err == nil {
				removed++
				if logger != nil {
					logger.Printf("[INFO] [web] download-export TTL sweep removed a staged bundle (age > %s)", maxAge)
				}
			}
		}
	}
	return removed
}
