package dispatch

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"fmt"
	"io"
	"os"
	"strings"

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

	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// Backup browse — list / read entries inside a backup tarball without
// extracting it. The agent has direct host access to the .tar.gz under
// $BACKUP_DIR, so we don't need a sidecar; standard library archive/tar
// + compress/gzip handles both the list and the single-file extract.
//
// Sized for safety: BackupReadFile caps the in-memory buffer per-call
// (default 8 MiB) so a malicious or corrupt tar entry can't OOM the
// agent. Truncation is reported back to the caller so the UI can show
// "(truncated, download to see full file)".

const (
	defaultBackupReadCap = 8 << 20  // 8 MiB
	maxBackupReadCap     = 64 << 20 // 64 MiB hard ceiling regardless of caller request
	binarySniffLen       = 8 << 10  // 8 KiB
)

func (d *Dispatcher) handleBackupListEntries(corrID string, req *panelv1.BackupListEntriesRequest) {
	d.log.Info("backup list entries: start", "correlation_id", corrID, "backup_path", req.BackupPath, "instance_id", req.InstanceId)
	if req.BackupPath == "" {
		d.sendBackupListEntriesResult(corrID, nil, 0, "backup_path required")
		return
	}
	f, err := os.Open(req.BackupPath)
	if err != nil {
		d.sendBackupListEntriesResult(corrID, nil, 0, "open backup: "+err.Error())
		return
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		d.sendBackupListEntriesResult(corrID, nil, 0, "gzip: "+err.Error())
		return
	}
	defer gz.Close()
	tr := tar.NewReader(gz)

	var entries []*panelv1.BackupTarEntry
	var totalBytes int64
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			d.sendBackupListEntriesResult(corrID, nil, 0, "tar header: "+err.Error())
			return
		}
		isDir := hdr.Typeflag == tar.TypeDir || strings.HasSuffix(hdr.Name, "/")
		clean := strings.TrimPrefix(hdr.Name, "./")
		clean = strings.TrimPrefix(clean, "/")
		entry := &panelv1.BackupTarEntry{
			Path:    clean,
			Size:    hdr.Size,
			IsDir:   isDir,
			Mode:    hdr.FileInfo().Mode().String(),
			ModTime: timestamppb.New(hdr.ModTime),
		}
		entries = append(entries, entry)
		if !isDir {
			totalBytes += hdr.Size
		}
	}
	d.log.Info("backup list entries: ok", "correlation_id", corrID, "count", len(entries), "total_bytes", totalBytes)
	d.sendBackupListEntriesResult(corrID, entries, totalBytes, "")
}

func (d *Dispatcher) handleBackupReadFile(corrID string, req *panelv1.BackupReadFileRequest) {
	if req.BackupPath == "" {
		d.sendBackupReadFileResult(corrID, nil, 0, false, false, "backup_path required")
		return
	}
	if req.EntryPath == "" {
		d.sendBackupReadFileResult(corrID, nil, 0, false, false, "entry_path required")
		return
	}
	maxRead := req.MaxBytes
	if maxRead <= 0 {
		maxRead = defaultBackupReadCap
	}
	if maxRead > maxBackupReadCap {
		maxRead = maxBackupReadCap
	}

	f, err := os.Open(req.BackupPath)
	if err != nil {
		d.sendBackupReadFileResult(corrID, nil, 0, false, false, "open backup: "+err.Error())
		return
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		d.sendBackupReadFileResult(corrID, nil, 0, false, false, "gzip: "+err.Error())
		return
	}
	defer gz.Close()
	tr := tar.NewReader(gz)

	// Match by exact path; tarballs sometimes prefix entries with "./" so
	// also accept that variant.
	want := strings.TrimPrefix(req.EntryPath, "/")
	wantAlt := "./" + want
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			d.sendBackupReadFileResult(corrID, nil, 0, false, false, fmt.Sprintf("entry %q not found in backup", req.EntryPath))
			return
		}
		if err != nil {
			d.sendBackupReadFileResult(corrID, nil, 0, false, false, "tar header: "+err.Error())
			return
		}
		if hdr.Name != want && hdr.Name != wantAlt {
			continue
		}
		if hdr.Typeflag == tar.TypeDir || strings.HasSuffix(hdr.Name, "/") {
			d.sendBackupReadFileResult(corrID, nil, hdr.Size, false, false, "entry is a directory")
			return
		}
		// Read up to maxRead+1 to detect truncation cleanly.
		buf := &bytes.Buffer{}
		_, err = io.Copy(buf, io.LimitReader(tr, maxRead+1))
		if err != nil {
			d.sendBackupReadFileResult(corrID, nil, hdr.Size, false, false, "read entry: "+err.Error())
			return
		}
		truncated := false
		content := buf.Bytes()
		if int64(len(content)) > maxRead {
			content = content[:maxRead]
			truncated = true
		}
		isBinary := looksBinary(content)
		d.sendBackupReadFileResult(corrID, content, hdr.Size, truncated, isBinary, "")
		return
	}
}

// looksBinary returns true if the first binarySniffLen bytes contain a
// NUL byte. Standard heuristic — text files don't contain NULs, binaries
// almost always do (executables, images, compressed save formats).
func looksBinary(b []byte) bool {
	limit := binarySniffLen
	if len(b) < limit {
		limit = len(b)
	}
	for i := 0; i < limit; i++ {
		if b[i] == 0 {
			return true
		}
	}
	return false
}

func (d *Dispatcher) sendBackupListEntriesResult(corrID string, entries []*panelv1.BackupTarEntry, totalBytes int64, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_BackupListEntriesResult{
			BackupListEntriesResult: &panelv1.BackupListEntriesResult{
				Entries:    entries,
				TotalBytes: totalBytes,
				Error:      errMsg,
			},
		},
	})
}

func (d *Dispatcher) sendBackupReadFileResult(corrID string, content []byte, size int64, truncated, isBinary bool, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_BackupReadFileResult{
			BackupReadFileResult: &panelv1.BackupReadFileResult{
				Content:   content,
				Size:      size,
				Truncated: truncated,
				IsBinary:  isBinary,
				Error:     errMsg,
			},
		},
	})
}
