package appbackup

import (
	"bufio"
	"context"
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"gopkg.in/yaml.v3"
)

// StackDataProvider provides stack data to the backup packages without circular imports.
type StackDataProvider interface {
	GetStackComposePath(name string) (composePath string, ok bool)
	ListDeployedStacks() []StackSummary
	GetStackHDDMounts(name string) []string
	GetStackHDDPath(name string) string    // raw HDD_PATH from app.yaml (empty if no HDD)
	GetDockerVolumes(name string) []string // full Docker volume names (project-prefixed)
	StopStack(name string) error
	StartStack(name string) error
	RefreshAndIsRunning(name string) bool
	// GetStackRecoveryInfo returns the data needed to capture a SECRET-FREE recovery unit
	// (Phase 2): the stack dir, pinned image tags, the non-secret env, and the NAMES of the
	// secret/data-key env vars (values are NEVER returned — they are recovered at restore time
	// from the guest's own app.yaml, live or via the PBS whole-guest snapshot). ok=false if the
	// stack is unknown.
	GetStackRecoveryInfo(name string) (RecoveryInfo, bool)

	// --- Phase 2b: restore-from-recovery-unit ---

	// RecoverStackSecrets returns the live decrypted values for the named secret env vars that are
	// currently present (non-empty) in the stack's app.yaml (the guest's own — live rootfs, or
	// PBS-restored). Names that are absent/empty are simply omitted from the map; the caller's
	// fail-closed gate decides what to do. The unit is never the source of secrets.
	RecoverStackSecrets(name string, names []string) map[string]string

	// RecreateStackFromUnit restores an app's definition from the unit's compose dir into the stack
	// dir, writes app.yaml from fullEnv (encrypting secret fields), and (re-)deploys it via
	// `docker compose up -d`, which re-pulls the pinned image. Secrets are NEVER regenerated.
	RecreateStackFromUnit(name, composeSrcDir string, fullEnv map[string]string) error
}

// RecoveryInfo carries everything needed to write a secret-free recovery unit for a stack.
// It deliberately holds NO secret values — only the names of secret/data-key env vars, so the
// manifest can record what must be recovered from elsewhere (guest app.yaml / PBS) without the
// unit ever storing a secret or a data-encrypting key.
type RecoveryInfo struct {
	StackDir       string            // dir holding docker-compose.yml + .felhom.yml + app.yaml
	DisplayName    string            // app display name
	ImagePins      []string          // pinned image tags from compose `image:` lines (re-pulled on restore)
	NonSecretEnv   map[string]string // env with all secret/password/data-key values removed (plaintext only)
	SecretEnvVars  []string          // NAMES of stripped secret/password fields (recovered from guest/PBS)
	DataKeyEnvVars []string          // NAMES of data-encrypting-key fields (fail-closed gate on restore)
}

// ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a
// docker-compose.yml, in file order, de-duplicated. The image bytes are never stored in the
// recovery unit — only these pins, so restore re-pulls from the registry.
func ParseComposeImages(composePath string) []string {
	data, err := os.ReadFile(composePath)
	if err != nil {
		return nil
	}
	var images []string
	seen := make(map[string]bool)
	scanner := bufio.NewScanner(strings.NewReader(string(data)))
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if !strings.HasPrefix(line, "image:") {
			continue
		}
		img := strings.TrimSpace(strings.TrimPrefix(line, "image:"))
		img = strings.Trim(img, "\"'")
		// Skip variable-only images we can't pin (e.g. image: ${SOME_IMAGE})
		if img == "" || strings.HasPrefix(img, "${") {
			continue
		}
		if !seen[img] {
			seen[img] = true
			images = append(images, img)
		}
	}
	return images
}

// StackSummary holds minimal stack info needed for app data discovery.
type StackSummary struct {
	Name        string
	DisplayName string
	ComposePath string
	NeedsHDD    bool
	HasVolumes  bool
}

// AppBackupInfo holds backup-relevant data paths for a deployed app.
type AppBackupInfo struct {
	StackName     string
	DisplayName   string
	NeedsHDD      bool
	HDDPaths      []AppDataPath
	HDDTotalSize  int64
	HDDSizeHuman  string
	DockerVolumes []AppDockerVolume
	BackupEnabled bool
	HasHDDData    bool
	HasDBDump     bool
	HasVolumeData bool
	StorageLabel  string // resolved from registered storage paths
}

// AppDataPath represents a single HDD bind mount path.
type AppDataPath struct {
	HostPath  string
	Exists    bool
	SizeHuman string
	SizeBytes int64
}

// AppDockerVolume represents a named Docker volume.
type AppDockerVolume struct {
	Name     string
	Contains string
}

// DiscoverAppData discovers backup-relevant data for all deployed apps.
// All apps with HDD data are backed up automatically (mandatory — no opt-in).
func DiscoverAppData(provider StackDataProvider, discoveredDBs []DiscoveredDB) []AppBackupInfo {
	if provider == nil {
		return nil
	}

	var result []AppBackupInfo

	for _, stack := range provider.ListDeployedStacks() {
		info := AppBackupInfo{
			StackName:   stack.Name,
			DisplayName: stack.DisplayName,
			NeedsHDD:    stack.NeedsHDD,
		}

		// Discover HDD bind mounts via adapter
		hddMounts := provider.GetStackHDDMounts(stack.Name)
		for _, mount := range hddMounts {
			path := AppDataPath{HostPath: mount}
			if fi, err := os.Stat(mount); err == nil && fi.IsDir() {
				path.Exists = true
				path.SizeBytes, path.SizeHuman = appDirSize(mount)
			}
			info.HDDPaths = append(info.HDDPaths, path)
			info.HDDTotalSize += path.SizeBytes
		}
		info.HDDSizeHuman = humanizeBytes(info.HDDTotalSize)
		info.HasHDDData = len(info.HDDPaths) > 0

		// Discover Docker named volumes from compose
		info.DockerVolumes = ParseComposeNamedVolumes(stack.ComposePath)
		info.HasVolumeData = len(info.DockerVolumes) > 0

		// Check if app has a DB container (already backed up via DB dump)
		for _, db := range discoveredDBs {
			if db.StackName == stack.Name {
				info.HasDBDump = true
				break
			}
		}

		// All apps with HDD data are backed up automatically (mandatory)
		info.BackupEnabled = info.HasHDDData

		result = append(result, info)
	}

	log.Printf("[INFO] [backup] Discovered app data: %d apps", len(result))
	return result
}

// ParseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
func ParseComposeNamedVolumes(composePath string) []AppDockerVolume {
	data, err := os.ReadFile(composePath)
	if err != nil {
		return nil
	}

	var compose struct {
		Volumes map[string]interface{} `yaml:"volumes"`
	}
	if err := yaml.Unmarshal(data, &compose); err != nil {
		return nil
	}

	var volumes []AppDockerVolume
	for name, cfg := range compose.Volumes {
		// Skip external volumes
		if cfgMap, ok := cfg.(map[string]interface{}); ok {
			if ext, ok := cfgMap["external"]; ok && ext == true {
				continue
			}
		}
		volumes = append(volumes, AppDockerVolume{Name: name})
	}
	return volumes
}

// ResolveDockerVolumeNames returns full Docker volume names with the compose project prefix.
// Docker Compose V2 creates volumes as <project>_<volumeName> where project = directory name.
func ResolveDockerVolumeNames(composePath string) []string {
	vols := ParseComposeNamedVolumes(composePath)
	if len(vols) == 0 {
		return nil
	}
	project := filepath.Base(filepath.Dir(composePath))
	names := make([]string, 0, len(vols))
	for _, v := range vols {
		names = append(names, project+"_"+v.Name)
	}
	return names
}

// appDirSize returns the total byte count and a human-readable string for a directory.
// H2/H3: Single du invocation with 30s timeout replaces two separate calls.
func appDirSize(path string) (int64, string) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	cmd := exec.CommandContext(ctx, "du", "-sb", path)
	output, err := cmd.Output()
	if err != nil {
		return 0, "?"
	}
	fields := strings.Fields(string(output))
	if len(fields) == 0 {
		return 0, "?"
	}
	var size int64
	if n, _ := fmt.Sscanf(fields[0], "%d", &size); n != 1 {
		return 0, "?"
	}
	return size, humanizeBytes(size)
}

// HumanizeBytes converts bytes to a human-readable string.
// Exported so the backup package can forward to it (shared helper).
func HumanizeBytes(b int64) string {
	return humanizeBytes(b)
}

// humanizeBytes converts bytes to a human-readable string.
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)
	}
}
