package dispatch

// Archive operations for the file browser:
//
//   - FsExtract  : expand a zip / tar / tar.gz / tar.bz2 archive that
//                  already lives inside the instance's volume.
//   - FsCompress : zip up one or more paths inside the instance into a
//                  destination .zip in the same volume.
//   - FsRename   : safe `mv` that respects the browseable_root sandbox.
//
// Every container-side path goes through the existing fs helper sidecar
// (see getFsTargetContainerID) so these all work on stopped instances.
//
// Implementation notes:
//   - Extract reads the archive bytes via CopyFileFromContainer, walks
//     the entries with the appropriate stdlib reader, and re-emits a
//     single tar stream that's piped to CopyTarToContainer in one API
//     call. That avoids N per-file writes.
//   - Compress walks each source via CopyDirFromContainer (tar stream
//     from Docker), and re-encodes the entries as zip. The final zip
//     bytes go back via CopyFileToContainer.
//   - We support zip + tar + tar.gz + tar.bz2 + tar.xz + 7z + rar.
//     7z and rar use pure-Go libraries (bodgit/sevenzip,
//     nwaples/rardecode/v2) so no native deps. Encrypted rars and
//     solid 7z volumes are surfaced as errors, not silently skipped.

import (
	"archive/tar"
	"archive/zip"
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"path"
	"path/filepath"
	"strings"
	"time"

	"google.golang.org/protobuf/types/known/timestamppb"

	"github.com/dbledeez/panel/agent/internal/archive"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// ---- FsExtract ----

func (d *Dispatcher) handleFsExtract(corrID string, req *panelv1.FsExtractRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendFsExtractResult(corrID, 0, 0, err.Error())
		return
	}
	if req.ArchivePath == "" {
		d.sendFsExtractResult(corrID, 0, 0, "archive_path is required")
		return
	}
	if d.useContainerOps(rec) {
		d.extractInContainer(corrID, rec, req)
		return
	}
	d.extractOnHost(corrID, rec, req)
}

func (d *Dispatcher) extractInContainer(corrID string, rec *instanceRecord, req *panelv1.FsExtractRequest) {
	archiveAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.ArchivePath)
	if err != nil {
		d.sendFsExtractResult(corrID, 0, 0, err.Error())
		return
	}
	destRel := req.DestDir
	if destRel == "" {
		// Default: same dir as the archive.
		destRel = path.Dir(req.ArchivePath)
	}
	destAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), destRel)
	if err != nil {
		d.sendFsExtractResult(corrID, 0, 0, err.Error())
		return
	}

	ctx, cancel := context.WithTimeout(context.Background(), fsCopyTimeout)
	defer cancel()
	targetID, err := d.getFsTargetContainerID(ctx, rec)
	if err != nil {
		d.sendFsExtractResult(corrID, 0, 0, err.Error())
		return
	}

	// Pull the archive bytes back from the container. CopyFileFromContainer
	// internally tar-walks Docker's response and returns just the file body.
	archiveData, err := d.runtime.CopyFileFromContainer(ctx, targetID, archiveAbs)
	if err != nil {
		d.sendFsExtractResult(corrID, 0, 0, fmt.Errorf("read archive: %w", err).Error())
		return
	}

	// Make sure the destination dir exists before we ship the tar stream.
	_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		"mkdir -p -- " + shellQuote(destAbs)})

	// Build a tar stream of the archive's contents and pipe it to
	// CopyTarToContainer. Doing this in a single round-trip keeps the
	// wall-time of "extract a 500 MB archive with 4000 entries" manageable.
	pr, pw := io.Pipe()
	emitErr := make(chan error, 1)
	var entries, bytesOut int64
	go func() {
		n, b, err := archive.WriteAsTar(archiveData, req.ArchivePath, pw)
		entries, bytesOut = n, b
		_ = pw.CloseWithError(err)
		emitErr <- err
	}()
	if err := d.runtime.CopyTarToContainer(ctx, targetID, destAbs, pr); err != nil {
		// Pipe gets closed with our error from the goroutine; whichever
		// side surfaces first wins. We prefer the goroutine error since
		// it carries archive-format detail.
		if encErr := <-emitErr; encErr != nil {
			d.sendFsExtractResult(corrID, entries, bytesOut, encErr.Error())
			return
		}
		d.sendFsExtractResult(corrID, entries, bytesOut, fmt.Errorf("copy to container: %w", err).Error())
		return
	}
	if encErr := <-emitErr; encErr != nil {
		d.sendFsExtractResult(corrID, entries, bytesOut, encErr.Error())
		return
	}
	d.sendFsExtractResult(corrID, entries, bytesOut, "")
}

// extractOnHost handles the rare host-fallback case (no container, no
// helper) — uses os primitives. Same path-safety contract as everywhere
// else: must stay under the instance's data path.
func (d *Dispatcher) extractOnHost(corrID string, rec *instanceRecord, req *panelv1.FsExtractRequest) {
	if rec.DataPath == "" {
		d.sendFsExtractResult(corrID, 0, 0, "no file storage available")
		return
	}
	archiveAbs, err := safeJoinHost(rec.DataPath, req.ArchivePath)
	if err != nil {
		d.sendFsExtractResult(corrID, 0, 0, err.Error())
		return
	}
	destRel := req.DestDir
	if destRel == "" {
		destRel = filepath.Dir(req.ArchivePath)
	}
	destAbs, err := safeJoinHost(rec.DataPath, destRel)
	if err != nil {
		d.sendFsExtractResult(corrID, 0, 0, err.Error())
		return
	}
	if err := os.MkdirAll(destAbs, 0o755); err != nil {
		d.sendFsExtractResult(corrID, 0, 0, err.Error())
		return
	}
	data, err := os.ReadFile(archiveAbs)
	if err != nil {
		d.sendFsExtractResult(corrID, 0, 0, err.Error())
		return
	}
	entries, bytesOut, err := archive.Walk(data, req.ArchivePath, func(name string, mode int64, isDir bool, body io.Reader) error {
		safeName := archive.SafeEntryPath(name)
		if safeName == "" {
			return nil
		}
		out := filepath.Join(destAbs, filepath.FromSlash(safeName))
		if isDir {
			return os.MkdirAll(out, 0o755)
		}
		if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
			return err
		}
		f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(mode&0o777))
		if err != nil {
			return err
		}
		if _, err := io.Copy(f, body); err != nil {
			_ = f.Close()
			return err
		}
		return f.Close()
	})
	if err != nil {
		d.sendFsExtractResult(corrID, entries, bytesOut, err.Error())
		return
	}
	d.sendFsExtractResult(corrID, entries, bytesOut, "")
}

func (d *Dispatcher) sendFsExtractResult(corrID string, entries, bytesOut int64, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_FsExtractResult{
			FsExtractResult: &panelv1.FsExtractResult{EntriesWritten: entries, BytesWritten: bytesOut, Error: errMsg},
		},
	})
}

// ---- FsCompress ----

func (d *Dispatcher) handleFsCompress(corrID string, req *panelv1.FsCompressRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendFsCompressResult(corrID, 0, 0, err.Error())
		return
	}
	if len(req.Sources) == 0 {
		d.sendFsCompressResult(corrID, 0, 0, "at least one source path is required")
		return
	}
	if req.DestZipPath == "" {
		d.sendFsCompressResult(corrID, 0, 0, "dest_zip_path is required")
		return
	}
	if d.useContainerOps(rec) {
		d.compressInContainer(corrID, rec, req)
		return
	}
	d.compressOnHost(corrID, rec, req)
}

func (d *Dispatcher) compressInContainer(corrID string, rec *instanceRecord, req *panelv1.FsCompressRequest) {
	destAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.DestZipPath)
	if err != nil {
		d.sendFsCompressResult(corrID, 0, 0, err.Error())
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), fsCopyTimeout)
	defer cancel()
	targetID, err := d.getFsTargetContainerID(ctx, rec)
	if err != nil {
		d.sendFsCompressResult(corrID, 0, 0, err.Error())
		return
	}

	var zipBuf bytes.Buffer
	zw := zip.NewWriter(&zipBuf)
	var entries, bytesOut int64

	for _, src := range req.Sources {
		srcAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), src)
		if err != nil {
			d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
			return
		}
		// CopyDirFromContainer returns Docker's tar stream of srcAbs.
		// For files: a single-entry tar. For dirs: every file under it.
		// The base name of srcAbs is the prefix Docker uses, which we
		// keep in the zip so the user gets familiar names back.
		rc, err := d.runtime.CopyDirFromContainer(ctx, targetID, srcAbs)
		if err != nil {
			d.sendFsCompressResult(corrID, entries, bytesOut, fmt.Errorf("copy from container %q: %w", src, err).Error())
			return
		}
		n, b, err := pipeTarIntoZip(rc, zw)
		_ = rc.Close()
		if err != nil {
			d.sendFsCompressResult(corrID, entries+n, bytesOut+b, fmt.Errorf("encode %q: %w", src, err).Error())
			return
		}
		entries += n
		bytesOut += b
	}
	if err := zw.Close(); err != nil {
		d.sendFsCompressResult(corrID, entries, bytesOut, fmt.Errorf("zip close: %w", err).Error())
		return
	}
	// Drop the resulting zip into the volume.
	_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		"mkdir -p -- " + shellQuote(path.Dir(destAbs))})
	if err := d.runtime.CopyFileToContainer(ctx, targetID, destAbs, zipBuf.Bytes()); err != nil {
		d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
		return
	}
	d.sendFsCompressResult(corrID, entries, bytesOut, "")
}

func (d *Dispatcher) compressOnHost(corrID string, rec *instanceRecord, req *panelv1.FsCompressRequest) {
	if rec.DataPath == "" {
		d.sendFsCompressResult(corrID, 0, 0, "no file storage available")
		return
	}
	destAbs, err := safeJoinHost(rec.DataPath, req.DestZipPath)
	if err != nil {
		d.sendFsCompressResult(corrID, 0, 0, err.Error())
		return
	}
	if err := os.MkdirAll(filepath.Dir(destAbs), 0o755); err != nil {
		d.sendFsCompressResult(corrID, 0, 0, err.Error())
		return
	}
	f, err := os.Create(destAbs)
	if err != nil {
		d.sendFsCompressResult(corrID, 0, 0, err.Error())
		return
	}
	zw := zip.NewWriter(f)
	var entries, bytesOut int64
	for _, src := range req.Sources {
		srcAbs, err := safeJoinHost(rec.DataPath, src)
		if err != nil {
			zw.Close()
			f.Close()
			d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
			return
		}
		base := filepath.Base(srcAbs)
		err = filepath.Walk(srcAbs, func(p string, info os.FileInfo, walkErr error) error {
			if walkErr != nil {
				return walkErr
			}
			rel, err := filepath.Rel(srcAbs, p)
			if err != nil {
				return err
			}
			zipName := base
			if rel != "." {
				zipName = filepath.ToSlash(filepath.Join(base, rel))
			}
			if info.IsDir() {
				if zipName == "" || zipName == "." {
					return nil
				}
				_, err = zw.Create(zipName + "/")
				return err
			}
			zf, err := zw.Create(zipName)
			if err != nil {
				return err
			}
			rf, err := os.Open(p)
			if err != nil {
				return err
			}
			defer rf.Close()
			n, err := io.Copy(zf, rf)
			if err != nil {
				return err
			}
			entries++
			bytesOut += n
			return nil
		})
		if err != nil {
			zw.Close()
			f.Close()
			d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
			return
		}
	}
	if err := zw.Close(); err != nil {
		f.Close()
		d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
		return
	}
	if err := f.Close(); err != nil {
		d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
		return
	}
	d.sendFsCompressResult(corrID, entries, bytesOut, "")
}

// pipeTarIntoZip walks a docker-style tar stream and writes each
// regular-file entry into the zip writer, preserving relative paths.
// Directory entries are written as zero-byte zip directory markers.
func pipeTarIntoZip(r io.Reader, zw *zip.Writer) (int64, int64, error) {
	tr := tar.NewReader(r)
	var entries, bytesOut, totalBytes int64
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			return entries, bytesOut, nil
		}
		if err != nil {
			return entries, bytesOut, err
		}
		safe := archive.SafeEntryPath(hdr.Name)
		if safe == "" {
			continue
		}
		switch hdr.Typeflag {
		case tar.TypeDir:
			if _, err := zw.Create(safe + "/"); err != nil {
				return entries, bytesOut, err
			}
			entries++
		case tar.TypeReg, tar.TypeRegA:
			zf, err := zw.Create(safe)
			if err != nil {
				return entries, bytesOut, err
			}
			n, err := io.Copy(zf, tr)
			if err != nil {
				return entries, bytesOut, err
			}
			entries++
			bytesOut += n
			totalBytes += n
			if totalBytes > archive.MaxTotalBytes {
				return entries, bytesOut, fmt.Errorf("compress: total uncompressed bytes exceed %d", archive.MaxTotalBytes)
			}
		}
	}
}

func (d *Dispatcher) sendFsCompressResult(corrID string, entries, bytesOut int64, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_FsCompressResult{
			FsCompressResult: &panelv1.FsCompressResult{EntriesWritten: entries, BytesWritten: bytesOut, Error: errMsg},
		},
	})
}

// ---- FsRename ----

func (d *Dispatcher) handleFsRename(corrID string, req *panelv1.FsRenameRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendFsRenameResult(corrID, err.Error())
		return
	}
	if req.FromPath == "" || req.ToPath == "" {
		d.sendFsRenameResult(corrID, "from_path and to_path are required")
		return
	}
	if d.useContainerOps(rec) {
		fromAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.FromPath)
		if err != nil {
			d.sendFsRenameResult(corrID, err.Error())
			return
		}
		toAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.ToPath)
		if err != nil {
			d.sendFsRenameResult(corrID, err.Error())
			return
		}
		if fromAbs == rec.BrowseableRoot {
			d.sendFsRenameResult(corrID, "refusing to rename instance root")
			return
		}
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		targetID, err := d.getFsTargetContainerID(ctx, rec)
		if err != nil {
			d.sendFsRenameResult(corrID, err.Error())
			return
		}
		// mkdir parent so a "rename to subdir" implicitly creates it.
		_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
			"mkdir -p -- " + shellQuote(path.Dir(toAbs))})
		_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
			"mv -- " + shellQuote(fromAbs) + " " + shellQuote(toAbs)})
		if err != nil {
			d.sendFsRenameResult(corrID, err.Error())
			return
		}
		if code != 0 {
			msg := strings.TrimSpace(string(stderr))
			if msg == "" {
				msg = fmt.Sprintf("mv exited %d", code)
			}
			d.sendFsRenameResult(corrID, msg)
			return
		}
		d.sendFsRenameResult(corrID, "")
		return
	}
	// Host fallback.
	if rec.DataPath == "" {
		d.sendFsRenameResult(corrID, "no file storage available")
		return
	}
	fromAbs, err := safeJoinHost(rec.DataPath, req.FromPath)
	if err != nil {
		d.sendFsRenameResult(corrID, err.Error())
		return
	}
	toAbs, err := safeJoinHost(rec.DataPath, req.ToPath)
	if err != nil {
		d.sendFsRenameResult(corrID, err.Error())
		return
	}
	if fromAbs == rec.DataPath {
		d.sendFsRenameResult(corrID, "refusing to rename instance root")
		return
	}
	if err := os.MkdirAll(filepath.Dir(toAbs), 0o755); err != nil {
		d.sendFsRenameResult(corrID, err.Error())
		return
	}
	if err := os.Rename(fromAbs, toAbs); err != nil {
		d.sendFsRenameResult(corrID, err.Error())
		return
	}
	d.sendFsRenameResult(corrID, "")
}

func (d *Dispatcher) sendFsRenameResult(corrID, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_FsRenameResult{
			FsRenameResult: &panelv1.FsRenameResult{Error: errMsg},
		},
	})
}
