// Package archive walks and re-encodes archive files (zip / tar /
// tar.gz / tar.bz2 / tar.xz / 7z / rar) fully in-process — no native
// deps. It is shared by the file browser's extract/compress endpoints
// (internal/dispatch) and the direct update provider
// (internal/updater), which extracts archive downloads into the
// instance instead of dumping the raw archive bytes.
//
// 7z and rar use pure-Go libraries (bodgit/sevenzip,
// nwaples/rardecode/v2). Encrypted rars and solid 7z volumes are
// surfaced as errors, not silently skipped.
package archive

import (
	"archive/tar"
	"archive/zip"
	"bytes"
	"compress/bzip2"
	"compress/gzip"
	"errors"
	"fmt"
	"io"
	"path"
	"strings"
	"time"

	"github.com/bodgit/sevenzip"
	"github.com/nwaples/rardecode/v2"
	"github.com/ulikunitz/xz"
)

// Entry-count / byte caps so a malformed or hostile archive can't pin
// the agent forever walking phantom records.
const (
	MaxEntries     = 200000
	MaxTotalBytes  = 8 * 1024 * 1024 * 1024
	EntrySoftBytes = 4 * 1024 * 1024 * 1024
)

// EntryFunc is called once per archive entry. body is a streaming
// reader (nil for directories); the callback may read fully or skip.
type EntryFunc func(name string, mode int64, isDir bool, body io.Reader) error

// Walk opens `data` as an archive (auto-detected via signature plus
// filename hint) and calls fn for each entry. Returns entry and byte
// counts alongside any error.
func Walk(data []byte, name string, fn EntryFunc) (int64, int64, error) {
	switch DetectFormat(data, name) {
	case "zip":
		return walkZip(data, fn)
	case "tar.gz":
		gz, err := gzip.NewReader(bytes.NewReader(data))
		if err != nil {
			return 0, 0, fmt.Errorf("gzip open: %w", err)
		}
		defer gz.Close()
		return walkTar(gz, fn)
	case "tar.bz2":
		return walkTar(bzip2.NewReader(bytes.NewReader(data)), fn)
	case "tar.xz":
		xr, err := xz.NewReader(bytes.NewReader(data))
		if err != nil {
			return 0, 0, fmt.Errorf("xz open: %w", err)
		}
		return walkTar(xr, fn)
	case "tar":
		return walkTar(bytes.NewReader(data), fn)
	case "7z":
		return walk7z(data, fn)
	case "rar":
		return walkRar(data, fn)
	default:
		return 0, 0, fmt.Errorf("unsupported archive format for %q (panel knows zip / tar / tar.gz / tar.bz2 / tar.xz / 7z / rar)", name)
	}
}

// WriteAsTar parses an archive from `data` and re-emits its entries as
// a tar stream into `out`. Used by container-side extract paths so the
// agent can feed Docker's CopyToContainer in a single API call.
func WriteAsTar(data []byte, originalName string, out io.Writer) (int64, int64, error) {
	tw := tar.NewWriter(out)
	defer tw.Close()
	var entries, bytesOut int64
	_, _, err := Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error {
		safe := SafeEntryPath(name)
		if safe == "" {
			return nil
		}
		// Buffer the entry body so we can compute its length before
		// writing the tar header. zip / gzip readers return streaming
		// readers; the tar writer needs Size up front.
		var bodyBuf bytes.Buffer
		var sz int64
		if !isDir {
			n, err := io.Copy(&bodyBuf, body)
			if err != nil {
				return fmt.Errorf("entry %q: %w", name, err)
			}
			sz = n
			if sz > EntrySoftBytes {
				return fmt.Errorf("entry %q exceeds %d-byte cap", name, int64(EntrySoftBytes))
			}
		}
		hdr := &tar.Header{Name: safe, ModTime: time.Now(), Mode: 0o644}
		if mode != 0 {
			hdr.Mode = mode & 0o777
		}
		if isDir {
			hdr.Typeflag = tar.TypeDir
			hdr.Mode = 0o755
			if !strings.HasSuffix(hdr.Name, "/") {
				hdr.Name += "/"
			}
		} else {
			hdr.Typeflag = tar.TypeReg
			hdr.Size = sz
		}
		if err := tw.WriteHeader(hdr); err != nil {
			return err
		}
		if !isDir {
			if _, err := tw.Write(bodyBuf.Bytes()); err != nil {
				return err
			}
		}
		entries++
		bytesOut += sz
		return nil
	})
	if err != nil {
		return entries, bytesOut, err
	}
	return entries, bytesOut, tw.Close()
}

// DetectFormat classifies `data` by magic bytes first (handles renamed
// extensions), then falls back to the filename hint. Returns "" when
// the content is not a recognized archive.
func DetectFormat(data []byte, name string) string {
	lower := strings.ToLower(name)
	// Magic bytes win when present — handles renamed extensions.
	if len(data) >= 4 && data[0] == 0x50 && data[1] == 0x4b && (data[2] == 0x03 || data[2] == 0x05 || data[2] == 0x07) {
		return "zip"
	}
	if len(data) >= 3 && data[0] == 0x1f && data[1] == 0x8b && data[2] == 0x08 {
		return "tar.gz"
	}
	if len(data) >= 3 && data[0] == 'B' && data[1] == 'Z' && data[2] == 'h' {
		return "tar.bz2"
	}
	// 7z signature: 37 7A BC AF 27 1C
	if len(data) >= 6 && data[0] == 0x37 && data[1] == 0x7a && data[2] == 0xbc && data[3] == 0xaf && data[4] == 0x27 && data[5] == 0x1c {
		return "7z"
	}
	// RAR 5.0 signature: 52 61 72 21 1A 07 01 00  ("Rar!\x1a\x07\x01\x00")
	// RAR 1.5–4.x:       52 61 72 21 1A 07 00     ("Rar!\x1a\x07\x00")
	if len(data) >= 7 && data[0] == 'R' && data[1] == 'a' && data[2] == 'r' && data[3] == '!' && data[4] == 0x1a && data[5] == 0x07 {
		return "rar"
	}
	// xz signature: FD 37 7A 58 5A 00
	if len(data) >= 6 && data[0] == 0xfd && data[1] == '7' && data[2] == 'z' && data[3] == 'X' && data[4] == 'Z' && data[5] == 0x00 {
		return "tar.xz"
	}
	if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
		return "tar.gz"
	}
	if strings.HasSuffix(lower, ".tar.bz2") || strings.HasSuffix(lower, ".tbz2") || strings.HasSuffix(lower, ".tbz") {
		return "tar.bz2"
	}
	if strings.HasSuffix(lower, ".tar.xz") || strings.HasSuffix(lower, ".txz") {
		return "tar.xz"
	}
	if strings.HasSuffix(lower, ".tar") {
		return "tar"
	}
	if strings.HasSuffix(lower, ".zip") {
		return "zip"
	}
	if strings.HasSuffix(lower, ".7z") {
		return "7z"
	}
	if strings.HasSuffix(lower, ".rar") {
		return "rar"
	}
	return ""
}

func walkZip(data []byte, fn EntryFunc) (int64, int64, error) {
	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
	if err != nil {
		return 0, 0, fmt.Errorf("zip open: %w", err)
	}
	var entries, bytesOut int64
	var totalBytes int64
	for _, f := range zr.File {
		if entries >= MaxEntries {
			return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
		}
		isDir := f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/")
		if isDir {
			if err := fn(f.Name, int64(f.Mode()), true, nil); err != nil {
				return entries, bytesOut, err
			}
			entries++
			continue
		}
		rc, err := f.Open()
		if err != nil {
			return entries, bytesOut, fmt.Errorf("zip entry %q: %w", f.Name, err)
		}
		// Cap each entry by `EntrySoftBytes` so a malformed/zip-bomb
		// entry can't run away with memory. We DO NOT trust
		// f.UncompressedSize64 — some zip writers leave it 0 for streamed
		// entries; using it for accounting (bytesOut += size) caused the
		// per-archive total to be charged the 4 GB soft cap per such
		// entry, tripping MaxTotalBytes after just a couple of
		// files and aborting the extract mid-stream — silently leaving
		// the first N files extracted and the rest dropped (this was the
		// root cause of the 7DTD mod-upload "Config/ folders missing"
		// bug). Use a counting reader so accounting reflects real bytes.
		cr := &countingReader{r: io.LimitReader(rc, EntrySoftBytes)}
		if err := fn(f.Name, int64(f.Mode()), false, cr); err != nil {
			_ = rc.Close()
			return entries, bytesOut, err
		}
		_ = rc.Close()
		entries++
		bytesOut += cr.n
		totalBytes += cr.n
		if totalBytes > MaxTotalBytes {
			return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
		}
	}
	return entries, bytesOut, nil
}

// countingReader wraps an io.Reader and tracks the byte count actually
// consumed. Used by walkZip / walkTar accounting so the per-archive
// total reflects real-world bytes, not zip-header declared sizes (which
// some encoders leave as 0).
type countingReader struct {
	r io.Reader
	n int64
}

func (c *countingReader) Read(p []byte) (int, error) {
	n, err := c.r.Read(p)
	c.n += int64(n)
	return n, err
}

func walkTar(r io.Reader, fn EntryFunc) (int64, int64, error) {
	tr := tar.NewReader(r)
	var entries, bytesOut, totalBytes int64
	for {
		if entries >= MaxEntries {
			return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
		}
		hdr, err := tr.Next()
		if err == io.EOF {
			return entries, bytesOut, nil
		}
		if err != nil {
			return entries, bytesOut, fmt.Errorf("tar next: %w", err)
		}
		switch hdr.Typeflag {
		case tar.TypeDir:
			if err := fn(hdr.Name, hdr.Mode, true, nil); err != nil {
				return entries, bytesOut, err
			}
			entries++
		case tar.TypeReg, tar.TypeRegA:
			if err := fn(hdr.Name, hdr.Mode, false, tr); err != nil {
				return entries, bytesOut, err
			}
			entries++
			bytesOut += hdr.Size
			totalBytes += hdr.Size
			if totalBytes > MaxTotalBytes {
				return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
			}
		}
	}
}

func walk7z(data []byte, fn EntryFunc) (int64, int64, error) {
	zr, err := sevenzip.NewReader(bytes.NewReader(data), int64(len(data)))
	if err != nil {
		return 0, 0, fmt.Errorf("7z open: %w", err)
	}
	var entries, bytesOut, totalBytes int64
	for _, f := range zr.File {
		if entries >= MaxEntries {
			return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
		}
		isDir := f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/")
		if isDir {
			if err := fn(f.Name, int64(f.Mode()), true, nil); err != nil {
				return entries, bytesOut, err
			}
			entries++
			continue
		}
		rc, err := f.Open()
		if err != nil {
			return entries, bytesOut, fmt.Errorf("7z entry %q: %w", f.Name, err)
		}
		// See countingReader rationale in walkZip — header-declared
		// size can be 0 for streamed entries, charging the soft-cap per
		// entry would falsely abort the extract.
		cr := &countingReader{r: io.LimitReader(rc, EntrySoftBytes)}
		if err := fn(f.Name, int64(f.Mode()), false, cr); err != nil {
			_ = rc.Close()
			return entries, bytesOut, err
		}
		_ = rc.Close()
		entries++
		bytesOut += cr.n
		totalBytes += cr.n
		if totalBytes > MaxTotalBytes {
			return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
		}
	}
	return entries, bytesOut, nil
}

func walkRar(data []byte, fn EntryFunc) (int64, int64, error) {
	rr, err := rardecode.NewReader(bytes.NewReader(data))
	if err != nil {
		return 0, 0, fmt.Errorf("rar open: %w", err)
	}
	var entries, bytesOut, totalBytes int64
	for {
		if entries >= MaxEntries {
			return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
		}
		hdr, err := rr.Next()
		if errors.Is(err, io.EOF) {
			return entries, bytesOut, nil
		}
		if err != nil {
			return entries, bytesOut, fmt.Errorf("rar next: %w", err)
		}
		mode := int64(hdr.Mode().Perm())
		if hdr.IsDir {
			if err := fn(hdr.Name, mode, true, nil); err != nil {
				return entries, bytesOut, err
			}
			entries++
			continue
		}
		// See countingReader rationale in walkZip.
		cr := &countingReader{r: io.LimitReader(rr, EntrySoftBytes)}
		if err := fn(hdr.Name, mode, false, cr); err != nil {
			return entries, bytesOut, err
		}
		entries++
		bytesOut += cr.n
		totalBytes += cr.n
		if totalBytes > MaxTotalBytes {
			return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
		}
	}
}

// SafeEntryPath strips leading slashes / drive letters and
// rejects any path that tries to ../ out of the destination. Returns
// "" on rejected entries — caller skips them silently.
func SafeEntryPath(name string) string {
	if name == "" {
		return ""
	}
	// Normalize separators + strip leading drive / slash.
	clean := strings.ReplaceAll(name, "\\", "/")
	for strings.HasPrefix(clean, "/") {
		clean = clean[1:]
	}
	if len(clean) >= 2 && clean[1] == ':' {
		clean = clean[2:]
	}
	clean = path.Clean(clean)
	if clean == "." || clean == "" {
		return ""
	}
	if strings.HasPrefix(clean, "../") || clean == ".." || strings.Contains(clean, "/../") {
		return ""
	}
	return clean
}
