package handler

import (
	"archive/zip"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"

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

// Edit-in-place for the .zddc.zip config bundle. A zip is a random-access
// container (unlike a streamed .tgz), so a member can be rewritten without
// re-encoding the operator's intent — we read the whole archive, mutate one
// member, snapshot the old version into an in-zip .history/, and atomically
// replace the file. Gated to active admins (dispatch already 404s the bundle
// to everyone else) and to the .zddc.zip bundle specifically; content zips stay
// read-only.
//
// History layout INSIDE the bundle (so edits travel with it):
//
//	.history/<member>/<RFC3339-nano timestamp>   the prior bytes
//	.history/<member>/log.jsonl                  append-only audit (ts, email, op)
//
// Writes serialize on one mutex — admin bundle edits are infrequent, and a
// whole-archive rewrite must not interleave.
var zipWriteMu sync.Mutex

const zipHistoryDir = ".history"

// ServeZipWrite handles PUT (write/create a member) and DELETE (remove a
// member) inside a .zddc.zip bundle. member is the path within the zip.
func ServeZipWrite(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbs, member string) {
	member = strings.TrimLeft(member, "/")
	if member == "" || strings.HasSuffix(member, "/") {
		http.Error(w, "Bad Request — a zip member path is required", http.StatusBadRequest)
		return
	}
	if member == zipHistoryDir || strings.HasPrefix(member, zipHistoryDir+"/") {
		http.Error(w, "Forbidden — the in-zip .history/ store is append-only", http.StatusForbidden)
		return
	}

	switch r.Method {
	case http.MethodPut:
		body, ok := readBodyCapped(cfg, w, r)
		if !ok {
			return
		}
		zipMutate(cfg, w, r, zipAbs, member, body, false)
	case http.MethodDelete:
		zipMutate(cfg, w, r, zipAbs, member, nil, true)
	default:
		w.Header().Set("Allow", "PUT, DELETE")
		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
	}
}

// zipLogLine is one append-only audit record in an in-zip log.jsonl.
type zipLogLine struct {
	TS    string `json:"ts"`
	Email string `json:"email"`
	Op    string `json:"op"`
	Bytes int    `json:"bytes"`
}

func zipMutate(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbs, member string, body []byte, del bool) {
	zipWriteMu.Lock()
	defer zipWriteMu.Unlock()

	members, order, err := readZipMembers(zipAbs)
	if err != nil {
		http.Error(w, "Internal Server Error — read bundle: "+err.Error(), http.StatusInternalServerError)
		return
	}

	old, existed := members[member]
	if del && !existed {
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}

	// Snapshot the prior bytes + append an audit line BEFORE mutating.
	if existed {
		ts := time.Now().UTC().Format("2006-01-02T15:04:05.000000000Z")
		histPrefix := zipHistoryDir + "/" + member + "/"
		addMember(members, &order, histPrefix+ts, old)
		op := "put"
		if del {
			op = "delete"
		}
		line, _ := json.Marshal(zipLogLine{TS: ts, Email: EmailFromContext(r), Op: op, Bytes: len(body)})
		logKey := histPrefix + "log.jsonl"
		members[logKey] = append(append(append([]byte{}, members[logKey]...), line...), '\n')
		// log.jsonl may be newly created here.
		ensureInOrder(&order, logKey)
	}

	if del {
		delete(members, member)
		order = removeFromOrder(order, member)
	} else {
		addMember(members, &order, member, body)
	}

	if err := writeZipAtomic(zipAbs, members, order); err != nil {
		auditFile(r, zipOp(del), r.URL.Path, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error — write bundle: "+err.Error(), http.StatusInternalServerError)
		return
	}

	// A .zddc.zip change can alter both policy (its .zddc members feed the
	// cascade) and tool HTML (apps.Bundle, which hot-reloads on mtime). Clear
	// the policy cache so the next decision re-reads the bundle.
	zddc.InvalidateCache(cfg.Root)

	if del {
		w.Header().Set("X-ZDDC-Source", "zip:delete")
		w.WriteHeader(http.StatusNoContent)
		auditFile(r, "zip-delete", r.URL.Path, http.StatusNoContent, 0, nil)
		return
	}
	status := http.StatusOK
	if !existed {
		status = http.StatusCreated
	}
	w.Header().Set("ETag", `"`+fileETag(body)+`"`)
	w.Header().Set("X-ZDDC-Source", "zip:put")
	w.WriteHeader(status)
	auditFile(r, "zip-put", r.URL.Path, status, len(body), nil)
}

func zipOp(del bool) string {
	if del {
		return "zip-delete"
	}
	return "zip-put"
}

// readZipMembers loads every member of the zip at zipAbs into a name→bytes map
// plus an order slice (insertion order, for stable rewrites).
func readZipMembers(zipAbs string) (map[string][]byte, []string, error) {
	zr, err := zip.OpenReader(zipAbs)
	if err != nil {
		return nil, nil, err
	}
	defer zr.Close()
	members := make(map[string][]byte, len(zr.File))
	order := make([]string, 0, len(zr.File))
	for _, f := range zr.File {
		if f.FileInfo().IsDir() {
			continue
		}
		rc, err := f.Open()
		if err != nil {
			return nil, nil, err
		}
		data, err := io.ReadAll(rc)
		rc.Close()
		if err != nil {
			return nil, nil, err
		}
		if _, dup := members[f.Name]; !dup {
			order = append(order, f.Name)
		}
		members[f.Name] = data
	}
	return members, order, nil
}

func addMember(members map[string][]byte, order *[]string, name string, data []byte) {
	if _, ok := members[name]; !ok {
		*order = append(*order, name)
	}
	members[name] = data
}

func ensureInOrder(order *[]string, name string) {
	for _, n := range *order {
		if n == name {
			return
		}
	}
	*order = append(*order, name)
}

func removeFromOrder(order []string, name string) []string {
	out := order[:0]
	for _, n := range order {
		if n != name {
			out = append(out, n)
		}
	}
	return out
}

// writeZipAtomic writes members to a fresh zip in the same directory and renames
// it over zipAbs. Members are emitted in `order` (sorted as a tiebreak for any
// not in order) so rewrites are deterministic.
func writeZipAtomic(zipAbs string, members map[string][]byte, order []string) error {
	// Any member not captured in order (defensive) goes last, sorted.
	seen := make(map[string]bool, len(order))
	for _, n := range order {
		seen[n] = true
	}
	var extra []string
	for n := range members {
		if !seen[n] {
			extra = append(extra, n)
		}
	}
	sort.Strings(extra)
	names := append(append([]string{}, order...), extra...)

	tmp, err := os.CreateTemp(filepath.Dir(zipAbs), ".zddc.zip.tmp-*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	defer os.Remove(tmpName) // no-op after a successful rename

	zw := zip.NewWriter(tmp)
	for _, name := range names {
		data, ok := members[name]
		if !ok {
			continue
		}
		fw, err := zw.Create(name)
		if err != nil {
			zw.Close()
			tmp.Close()
			return err
		}
		if _, err := fw.Write(data); err != nil {
			zw.Close()
			tmp.Close()
			return err
		}
	}
	if err := zw.Close(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpName, zipAbs)
}
