package apps

import (
	"archive/zip"
	"bytes"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"sync"
	"time"

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

// BundleName is the site-root config bundle that supplies local tool-HTML
// overrides (and, in future, templated config). It lives at
// <ZDDC_ROOT>/.zddc.zip. It is dot-hidden and 404-gated over HTTP (it's
// config, not browsable content); the server reads it from the filesystem
// internally, so members resolve for any user regardless of the HTTP gate.
const BundleName = ".zddc.zip"

// maxBundleBytes caps the whole .zddc.zip read into memory. The bundle is
// small config (a handful of HTML files), so a generous cap is fine.
const maxBundleBytes = 64 << 20 // 64 MiB

// maxBundleMemberBytes caps a single extracted member.
const maxBundleMemberBytes = 32 << 20 // 32 MiB

// Bundle is the cached parsed view of <ZDDC_ROOT>/.zddc.zip. A nil *Bundle
// is valid and behaves as "no bundle present" for all methods. Member()
// re-stats the file each call (cheap, and gives free hot-reload when an
// operator drops in a new bundle), reparsing only when mtime or size change.
type Bundle struct {
	path   string
	logger *slog.Logger

	mu      sync.Mutex
	data    []byte
	reader  *zip.Reader
	modTime time.Time
	size    int64
	loaded  bool // a valid zip is parsed into reader
}

// NewBundle returns a Bundle bound to <zddcRoot>/.zddc.zip. The file need
// not exist; Member returns (nil,false) until it does.
func NewBundle(zddcRoot string, logger *slog.Logger) *Bundle {
	if logger == nil {
		logger = slog.Default()
	}
	return &Bundle{path: filepath.Join(zddcRoot, BundleName), logger: logger}
}

// Member returns the bytes of the named member (e.g. "browse.html") from the
// bundle, or (nil,false) when the bundle is absent, unreadable, corrupt, or
// has no such member. Lookup is case-insensitive (via zipfs), matching the
// rest of the server's URL case-folding.
func (b *Bundle) Member(name string) ([]byte, bool) {
	if b == nil {
		return nil, false
	}
	b.mu.Lock()
	defer b.mu.Unlock()

	info, err := os.Stat(b.path)
	if err != nil || info.IsDir() {
		// Absent (or replaced by a dir) → no bundle. Drop any stale parse.
		b.data, b.reader, b.loaded = nil, nil, false
		return nil, false
	}
	if !b.loaded || info.ModTime() != b.modTime || info.Size() != b.size {
		if !b.reparse(info) {
			return nil, false
		}
	}
	rc, _, _, _, ok := zipfs.OpenMember(b.reader, name)
	if !ok {
		return nil, false
	}
	defer rc.Close()
	body, err := io.ReadAll(io.LimitReader(rc, maxBundleMemberBytes+1))
	if err != nil || int64(len(body)) > maxBundleMemberBytes {
		b.logger.Warn("zddc.zip member unreadable or too large", "member", name)
		return nil, false
	}
	return body, true
}

// reparse re-reads + re-parses the bundle. Caller holds b.mu. On any error
// the bundle is treated as absent (loaded=false) and the server falls back
// to embedded. Returns true when a valid reader is in place.
func (b *Bundle) reparse(info os.FileInfo) bool {
	b.data, b.reader, b.loaded = nil, nil, false
	if info.Size() > maxBundleBytes {
		b.logger.Warn("zddc.zip too large; ignoring", "size", info.Size(), "cap", maxBundleBytes)
		return false
	}
	data, err := os.ReadFile(b.path)
	if err != nil {
		b.logger.Warn("zddc.zip unreadable; ignoring", "err", err)
		return false
	}
	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
	if err != nil {
		b.logger.Warn("zddc.zip is not a valid zip; ignoring", "err", err)
		return false
	}
	b.data = data
	b.reader = zr
	b.modTime = info.ModTime()
	b.size = info.Size()
	b.loaded = true
	return true
}
