// Package zipfs presents the contents of a .zip file on disk as a
// browsable virtual directory tree: a JSON listing at any directory
// level inside the zip, and on-demand extraction of a single member.
//
// It does no HTTP and no ACL — that's the caller's job (see
// handler.ServeZip). The model mirrors what the browse tool already
// does client-side with JSZip: the zip's central directory is a flat
// list of member names; directory levels are reconstructed on top,
// synthesising the intermediate directories that have no explicit
// "<dir>/" entry of their own.
//
// Member names are sanitised against zip-slip (no "..", no absolute
// paths, no backslashes); unsafe entries are silently dropped.
package zipfs

import (
	"archive/zip"
	"io"
	"net/url"
	"path"
	"sort"
	"strings"
	"time"

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

// Open opens the zip file at zipAbsPath for reading. The caller must
// Close the returned *zip.ReadCloser. Errors propagate os.Open errors
// (use os.IsNotExist) and zip-format errors.
func Open(zipAbsPath string) (*zip.ReadCloser, error) {
	return zip.OpenReader(zipAbsPath)
}

// cleanMember normalises a raw zip entry name. Returns ("", false) for
// anything unsafe to surface: absolute paths, backslash separators,
// and any path that escapes the zip root via "..". A trailing slash
// (the zip convention for an explicit directory entry) is preserved.
func cleanMember(name string) (string, bool) {
	if name == "" || strings.ContainsRune(name, '\\') || strings.HasPrefix(name, "/") {
		return "", false
	}
	isDir := strings.HasSuffix(name, "/")
	cl := path.Clean(name)
	if cl == "." || cl == ".." || strings.HasPrefix(cl, "../") {
		return "", false
	}
	if isDir && cl != "" {
		cl += "/"
	}
	return cl, true
}

// normPrefix turns a member-path string ("", "a", "a/b", "a/b/") into
// the canonical "directory level" form: no leading or trailing slash.
func normPrefix(p string) string {
	return strings.Trim(p, "/")
}

// Open locates a *file* member matching memberPath inside r and returns
// a reader for its bytes plus its size and modtime. Matching is
// case-insensitive on the full path (segment-for-segment), mirroring
// the URL case-folding the rest of the server does. ok is false when
// no file member matches (it may still be a directory level — call
// List).
//
// memberPath must be the cleaned, slash-separated path with no leading
// or trailing slash (e.g. "sub/doc.pdf").
func OpenMember(r *zip.Reader, memberPath string) (rc io.ReadCloser, size int64, mod time.Time, name string, ok bool) {
	want := normPrefix(memberPath)
	if want == "" {
		return nil, 0, time.Time{}, "", false
	}
	for _, f := range r.File {
		cl, good := cleanMember(f.Name)
		if !good {
			continue
		}
		if strings.HasSuffix(cl, "/") {
			continue // directory entry
		}
		if strings.EqualFold(cl, want) {
			h, err := f.Open()
			if err != nil {
				return nil, 0, time.Time{}, "", false
			}
			return h, int64(f.UncompressedSize64), f.Modified, path.Base(cl), true
		}
	}
	return nil, 0, time.Time{}, "", false
}

// IsDirLevel reports whether prefix names a directory level inside r.
// The zip root ("") always qualifies. Otherwise it's true when there's
// an explicit "<prefix>/" entry, or any entry sits under "<prefix>/".
func IsDirLevel(r *zip.Reader, prefix string) bool {
	pfx := normPrefix(prefix)
	if pfx == "" {
		return true
	}
	under := pfx + "/"
	for _, f := range r.File {
		cl, ok := cleanMember(f.Name)
		if !ok {
			continue
		}
		// An explicit "<pfx>/" entry confirms the level; so does any
		// entry that lives under it. A *file* entry named exactly pfx
		// does NOT make pfx a directory.
		if strings.HasSuffix(cl, "/") && strings.TrimSuffix(cl, "/") == pfx {
			return true
		}
		if strings.HasPrefix(strings.TrimSuffix(cl, "/"), under) {
			return true
		}
	}
	return false
}

// List returns the immediate children of the directory level named by
// prefix ("" = the zip root) as Caddy-style FileInfo entries, with URL
// = baseURL + escaped-name. Synthesised intermediate directories (no
// explicit "<dir>/" entry) are included. valid is false when prefix
// names no directory level in the zip (no entry is at-or-under it).
//
// baseURL should end with "/" — it's the URL prefix for this level
// (the request path, e.g. "/P/staging/Foo.zip/" or
// "/P/staging/Foo.zip/sub/").
func List(r *zip.Reader, prefix, baseURL string) ([]listing.FileInfo, bool) {
	pfx := normPrefix(prefix)
	var under string // the "<pfx>/" string we strip, or "" at root
	if pfx != "" {
		under = pfx + "/"
	}

	type child struct {
		isDir   bool
		size    int64
		mod     time.Time
		mode    uint32
		explicit bool // backed by a real zip entry (vs. synthesised)
	}
	children := map[string]*child{}
	sawLevel := pfx == "" // the root level always exists

	for _, f := range r.File {
		cl, good := cleanMember(f.Name)
		if !good {
			continue
		}
		entryIsDir := strings.HasSuffix(cl, "/")
		bare := strings.TrimSuffix(cl, "/")
		if pfx != "" {
			if bare == pfx {
				// Entry is the prefix itself: an explicit "<pfx>/" dir
				// entry confirms the level; a file with that exact name
				// is not a directory, so don't count it.
				if entryIsDir {
					sawLevel = true
				}
				continue
			}
			if !strings.HasPrefix(bare, under) {
				continue
			}
		}
		sawLevel = true
		rest := bare
		if pfx != "" {
			rest = strings.TrimPrefix(bare, under)
		}
		if rest == "" {
			continue
		}
		seg := rest
		nested := false
		if i := strings.IndexByte(rest, '/'); i >= 0 {
			seg = rest[:i]
			nested = true
		}
		c := children[seg]
		if c == nil {
			c = &child{}
			children[seg] = c
		}
		if nested {
			// seg is an intermediate directory on the way to a deeper
			// member. Synthesised unless an explicit entry upgrades it.
			c.isDir = true
		} else if entryIsDir {
			c.isDir = true
			c.explicit = true
			c.mod = f.Modified
			c.mode = uint32(f.Mode())
		} else {
			// Immediate file child. (If a dir and a file share a name —
			// malformed zip — the file wins; harmless.)
			if !c.explicit || !c.isDir {
				c.isDir = false
				c.explicit = true
				c.size = int64(f.UncompressedSize64)
				c.mod = f.Modified
				c.mode = uint32(f.Mode())
			}
		}
	}

	if !sawLevel {
		return nil, false
	}

	out := make([]listing.FileInfo, 0, len(children))
	for name, c := range children {
		fi := listing.FileInfo{
			Name:    name,
			Size:    c.size,
			ModTime: c.mod,
			Mode:    c.mode,
			IsDir:   c.isDir,
			URL:     baseURL + url.PathEscape(name),
		}
		if c.isDir {
			fi.Name += "/"
			fi.URL += "/"
		}
		out = append(out, fi)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].IsDir != out[j].IsDir {
			return out[i].IsDir // directories first
		}
		return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
	})
	return out, true
}

// ResolveCloser is a tiny convenience for the handler: open the zip,
// returning the reader and a close func, or (nil, nil, err).
func ResolveCloser(zipAbsPath string) (*zip.Reader, func() error, error) {
	rc, err := Open(zipAbsPath)
	if err != nil {
		return nil, nil, err
	}
	return &rc.Reader, rc.Close, nil
}

// IsZipName reports whether name has a ".zip" extension (case-insensitive).
func IsZipName(name string) bool {
	return strings.EqualFold(path.Ext(name), ".zip")
}
