package handler

import (
	"bytes"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"os"
	"path"
	"strings"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zipfs"
)

// maxZipMemberBytes caps the size of a single zip member the server
// will materialise in memory to extract. Zip transmittals carry
// documents, not multi-gigabyte blobs; a member declaring more than
// this in its central-directory header is refused (413) rather than
// buffered. Streaming/large-member support is a future optimisation.
const maxZipMemberBytes = 512 << 20 // 512 MiB

// ServeZip serves the contents of the .zip file at zipAbsPath as a
// virtual directory. memberPath is the slash-separated path inside the
// zip (already URL-decoded; "" = the zip's root level).
//
// Only reached via the dispatcher's splitZipPath intercept, which has
// already (a) confirmed zipAbsPath is a regular .zip file under
// cfg.Root and (b) enforced ACL using the chain of the directory
// CONTAINING the zip — a zip has no .zddc of its own, exactly like the
// .archive virtual path. Write methods are rejected by the dispatcher
// before this is reached; zip access here is read-only.
//
// Routing inside the zip mirrors the server's slash convention:
//   - memberPath names a file member       → extract + stream it
//   - memberPath names a directory level    → JSON listing (Accept: json)
//                                             or the browse SPA (HTML)
//   - request had no trailing slash but the
//     member is a directory level           → 302 to add the slash
//   - otherwise                             → 404
func ServeZip(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbsPath, memberPath string) {
	zr, closeZip, err := zipfs.ResolveCloser(zipAbsPath)
	if err != nil {
		if os.IsNotExist(err) {
			http.Error(w, "Not Found", http.StatusNotFound)
		} else {
			slog.Warn("open zip archive", "path", zipAbsPath, "err", err)
			http.Error(w, "Bad zip archive", http.StatusBadGateway)
		}
		return
	}
	defer closeZip()

	member := strings.Trim(memberPath, "/")
	zipName := path.Base(zipAbsPath)

	// File member?
	if member != "" {
		if rc, size, mod, name, ok := zipfs.OpenMember(zr, member); ok {
			defer rc.Close()
			if size > maxZipMemberBytes {
				http.Error(w, "Zip member too large to serve", http.StatusRequestEntityTooLarge)
				return
			}
			// Buffer into memory so http.ServeContent gets a ReadSeeker
			// (Range / conditional GET). Bounded by maxZipMemberBytes.
			buf, err := io.ReadAll(rc)
			if err != nil {
				slog.Warn("extract zip member", "zip", zipAbsPath, "member", member, "err", err)
				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
				return
			}
			w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
			w.Header().Set("X-ZDDC-Source", "zip:"+zipName)
			http.ServeContent(w, r, name, mod, bytes.NewReader(buf))
			return
		}
	}

	// Directory level. The dispatcher only routes here when the URL had
	// a tail segment or a trailing slash, so a no-slash URL that lands
	// on a directory level gets normalised to the slash form.
	baseURL := r.URL.Path
	if !strings.HasSuffix(baseURL, "/") {
		if zipfs.IsDirLevel(zr, member) {
			http.Redirect(w, r, baseURL+"/", http.StatusFound)
			return
		}
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}
	entries, valid := zipfs.List(zr, member, baseURL)
	if !valid {
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}

	// Vary: Accept — same URL serves the JSON listing or the browse
	// SPA depending on Accept; without it caches can cross the wires.
	w.Header().Set("Vary", "Accept")

	if strings.Contains(r.Header.Get("Accept"), "application/json") {
		body, err := json.Marshal(entries)
		if err != nil {
			slog.Error("encoding zip listing", "err", err)
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
		etag := `"` + listingETag(body) + `"`
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("ETag", etag)
		w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
		w.Header().Set("X-ZDDC-Source", "zip:"+zipName)
		if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
			w.WriteHeader(http.StatusNotModified)
			return
		}
		_, _ = w.Write(body)
		return
	}

	// HTML: serve the embedded `browse` SPA, exactly like
	// ServeDirectory's and ServeArchive's HTML branches. It auto-detects
	// server mode by re-fetching this URL with Accept: application/json
	// (→ the JSON branch above), then renders the zip's contents.
	body := apps.EmbeddedBytes("browse")
	if len(body) == 0 {
		jsonBody, _ := json.Marshal(entries)
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "no-cache")
		_, _ = w.Write(jsonBody)
		return
	}
	etag := `"` + apps.EmbeddedETag("browse") + `"`
	w.Header().Set("ETag", etag)
	w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("X-ZDDC-Source", "embedded:browse")
	if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
		w.WriteHeader(http.StatusNotModified)
		return
	}
	_, _ = w.Write(body)
}
