package appexport

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"
)

// ExportEstimate holds pre-export size and space estimation.
type ExportEstimate struct {
	ConfigSizeBytes  int64  `json:"config_size_bytes"`
	ConfigSizeHuman  string `json:"config_size_human"`
	DataSizeBytes    int64  `json:"data_size_bytes"`
	DataSizeHuman    string `json:"data_size_human"`
	TotalSizeBytes   int64  `json:"total_size_bytes"`
	TotalSizeHuman   string `json:"total_size_human"`
	EstimatedMinutes int    `json:"estimated_minutes"`
	DestFreeBytes    int64  `json:"dest_free_bytes"`
	DestFreeHuman    string `json:"dest_free_human"`
	FitsOnDest       bool   `json:"fits_on_dest"`
	// SizeUnknown is set (v0.129.0 F-A) when a volume's size could not be read (docker helper
	// failed). When true, DataSizeBytes is a partial/understated sum and FitsOnDest is FORCED false
	// — a failed read must NEVER render as "fits". The UI shows "ismeretlen méret".
	SizeUnknown bool `json:"size_unknown"`

	// Task 4 class split (classified apps only; empty for legacy — existing fields above are
	// unchanged, so old JSON consumers keep working). BaseBytes = config + DB + volumes + mandatory
	// (always in the bundle). OptionalItems are pre-selected, ExcludedItems are opt-in — each carries
	// its own size so the UI recomputes the total client-side per checkbox toggle (no extra du calls).
	HasClassification bool      `json:"has_classification"`
	BaseBytes         int64     `json:"base_bytes"`
	BaseHuman         string    `json:"base_human"`
	MandatoryItems    []FabItem `json:"mandatory_items,omitempty"`
	OptionalItems     []FabItem `json:"optional_items,omitempty"`
	ExcludedItems     []FabItem `json:"excluded_items,omitempty"`
}

// FabItem is one class-scoped path in the `.fab` selection UI: Key is the DeselectOptional/OptInExcluded
// value ("root/rel"), RelPath is the display path, Bytes/Human its du size.
type FabItem struct {
	Key     string `json:"key"`
	Root    string `json:"root"`
	RelPath string `json:"rel_path"`
	Bytes   int64  `json:"bytes"`
	Human   string `json:"human"`
}

// EstimateExport calculates size estimates for an app export.
func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate, error) {
	stackDir, ok := e.provider.GetStackDir(stackName)
	if !ok {
		return nil, fmt.Errorf("stack %q not found", stackName)
	}

	e.debugf("EstimateExport: stack=%s stackDir=%s destDrive=%s", stackName, stackDir, destDrive)
	est := &ExportEstimate{}

	// Config size: sum of all files in the stack directory
	est.ConfigSizeBytes = dirSize(stackDir)
	est.ConfigSizeHuman = humanizeBytes(est.ConfigSizeBytes)
	e.debugf("EstimateExport: configSize=%s (%d bytes)", est.ConfigSizeHuman, est.ConfigSizeBytes)

	// Data size: HDD bind mounts PLUS Docker volumes. v0.130.0 (C6B-F1): additive, mirroring the
	// export itself — a needs_hdd app bundles BOTH, so the fits-on-dest gate must count both.
	if e.provider.GetStackNeedsHDD(stackName) {
		mounts := e.provider.GetStackHDDMounts(stackName)
		e.debugf("EstimateExport: HDD mounts: %v", mounts)
		for _, mount := range mounts {
			mountSize := duBytes(mount)
			e.debugf("EstimateExport: mount %s = %s", mount, humanizeBytes(mountSize))
			est.DataSizeBytes += mountSize
		}
	}
	volumes := e.provider.GetDockerVolumes(stackName)
	e.debugf("EstimateExport: Docker volumes: %v", volumes)
	var volumeBytes int64
	for _, vol := range volumes {
		volSize, err := volumeSizer(vol)
		if err != nil {
			// F-A: the controller runs containerized, so a failed helper read must not
			// silently become 0-that-reads-as-fits. Mark unknown and keep going.
			e.logger.Printf("[WARN] appexport: volume size unknown for %s: %v", vol, err)
			est.SizeUnknown = true
			continue
		}
		e.debugf("EstimateExport: volume %s = %s", vol, humanizeBytes(volSize))
		est.DataSizeBytes += volSize
		volumeBytes += volSize
	}
	if est.SizeUnknown {
		est.DataSizeHuman = "ismeretlen méret"
	} else {
		est.DataSizeHuman = humanizeBytes(est.DataSizeBytes)
	}

	est.TotalSizeBytes = est.ConfigSizeBytes + est.DataSizeBytes
	est.TotalSizeHuman = humanizeBytes(est.TotalSizeBytes)

	// Task 4: the class split (classified apps only). Independent du over each bucket path — additive,
	// never touches the fields/fits gate above.
	e.fabEstimateSplit(stackName, est, volumeBytes)

	// Rough time estimate: ~500 MB/min for HDDs, minimum 1 minute
	minutes := int(est.TotalSizeBytes / (500 * 1024 * 1024))
	if minutes < 1 {
		minutes = 1
	}
	est.EstimatedMinutes = minutes

	// Destination free space
	exportDir := ExportDir(destDrive)
	os.MkdirAll(exportDir, 0755)
	est.DestFreeBytes = DiskFree(exportDir)
	est.DestFreeHuman = humanizeBytes(est.DestFreeBytes)

	// Need ~10% overhead for tar.gz metadata + compression margin. F-A: a size we could not read
	// must never render as "fits" — an unknown-size estimate is conservatively not-fits.
	needed := est.TotalSizeBytes + est.TotalSizeBytes/10
	est.FitsOnDest = !est.SizeUnknown && est.DestFreeBytes >= needed

	e.debugf("EstimateExport: total=%s free=%s fits=%v needed=%s minutes=%d",
		est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest, humanizeBytes(needed), est.EstimatedMinutes)

	return est, nil
}

// dirSize returns the total size of all files in a directory (non-recursive for config dirs).
func dirSize(dir string) int64 {
	var total int64
	entries, err := os.ReadDir(dir)
	if err != nil {
		return 0
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		total += info.Size()
	}
	return total
}

// duBytes runs du -sb on a path and returns the byte count.
func duBytes(path string) int64 {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	out, err := exec.CommandContext(ctx, "du", "-sb", path).Output()
	if err != nil {
		return 0
	}
	var size int64
	fmt.Sscanf(strings.Fields(string(out))[0], "%d", &size)
	return size
}

// volumeSizer returns the byte size of a named Docker volume as seen from a CONTAINER view.
// Package var so unit tests inject a fake (returning a known size or an error) without shelling out
// to real docker. F-A (v0.129.0): the old dockerVolumeSize `du`d the host mountpoint from
// `docker volume inspect`, which is NOT visible inside the containerized controller → always 0.
var volumeSizer = realVolumeSize

// realVolumeSize `du -sb`s the volume mounted read-only into a throwaway helper container — the same
// container-view pattern the export path uses (appexport/export.go withVolumeHelper). It mounts the
// NAMED VOLUME by name (never a controller-host path — the v0.125.0 strand class). Returns an error
// on any failure; callers treat that as "unknown size", never as 0.
func realVolumeSize(volumeName string) (int64, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	var out bytes.Buffer
	stderr, err := dockerExec(ctx, nil, &out, "run", "--rm", "-v", volumeName+":/vol:ro", "alpine", "du", "-sb", "/vol")
	if err != nil {
		return 0, fmt.Errorf("sizing volume %s: %s: %w", volumeName, stderr, err)
	}
	fields := strings.Fields(out.String())
	if len(fields) == 0 {
		return 0, fmt.Errorf("sizing volume %s: empty du output", volumeName)
	}
	var size int64
	if _, err := fmt.Sscanf(fields[0], "%d", &size); err != nil {
		return 0, fmt.Errorf("sizing volume %s: parse %q: %w", volumeName, fields[0], err)
	}
	return size, nil
}

// DiskFree returns available bytes on the filesystem containing path (0 on any error).
// Exported since v0.128.0 — the browser-upload space gate reuses it via a web-package seam.
func DiskFree(path string) int64 {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	out, err := exec.CommandContext(ctx, "df", "--output=avail", "-B1", path).Output()
	if err != nil {
		return 0
	}
	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
	if len(lines) < 2 {
		return 0
	}
	var size int64
	fmt.Sscanf(strings.TrimSpace(lines[1]), "%d", &size)
	return size
}

// ExportDir returns the exports directory on a drive. Model A (slice 10): a registered drive's
// in-guest mount IS the felhom-data namespace root, so exports/ sits directly under it (no
// felhom-data segment — avoids the .../felhom-data/felhom-data/... double-nest).
func ExportDir(drivePath string) string {
	return filepath.Join(drivePath, "exports")
}

// humanizeBytes converts bytes to human-readable format.
func humanizeBytes(b int64) string {
	const (
		KB = 1024
		MB = KB * 1024
		GB = MB * 1024
	)
	switch {
	case b >= GB:
		return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
	case b >= MB:
		return fmt.Sprintf("%.1f MB", float64(b)/float64(MB))
	case b >= KB:
		return fmt.Sprintf("%.1f KB", float64(b)/float64(KB))
	default:
		return fmt.Sprintf("%d B", b)
	}
}
