package backup

import (
	"archive/tar"
	"compress/gzip"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"zlh-agent/internal/provision"
	"zlh-agent/internal/state"
	"zlh-agent/internal/system"
)

const (
	RootDir         = "/opt/zlh-agent/backups"
	defaultMaxCount = 10
	manifestName    = "backup_manifest.json"

	BackupTypeManual     = "manual"
	BackupTypeCheckpoint = "checkpoint"

	BackupReasonPreRestore = "pre_restore"
)

type Manifest struct {
	ID            string   `json:"id"`
	CreatedAtUTC  string   `json:"created_at_utc"`
	Type          string   `json:"type"`
	Reason        string   `json:"reason,omitempty"`
	ContainerType string   `json:"container_type"`
	Game          string   `json:"game"`
	Variant       string   `json:"variant"`
	Version       string   `json:"version"`
	VMID          int      `json:"vmid"`
	Archive       string   `json:"archive"`
	Paths         []string `json:"paths"`
	FileCount     int      `json:"file_count"`
	TotalBytes    int64    `json:"total_bytes"`
}

type RestoreResult struct {
	Backup     Manifest `json:"backup"`
	Checkpoint Manifest `json:"checkpoint"`
}

type createOptions struct {
	backupType string
	reason     string
	prune      bool
}

var (
	rootDir           = RootDir
	serverDir         = provision.ServerDir
	getServerPID      = system.GetServerPID
	runSaveOff        = system.RunMinecraftSaveOff
	runSaveOn         = system.RunMinecraftSaveOn
	stopServerAndWait = system.StopServerAndWait
	startServerReady  = system.StartServerReady
)

func Create(cfg *state.Config) (Manifest, error) {
	return create(cfg, createOptions{
		backupType: BackupTypeManual,
		prune:      true,
	})
}

func createCheckpoint(cfg *state.Config) (Manifest, error) {
	return create(cfg, createOptions{
		backupType: BackupTypeCheckpoint,
		reason:     BackupReasonPreRestore,
		prune:      false,
	})
}

func create(cfg *state.Config, opts createOptions) (Manifest, error) {
	if err := requireMinecraft(cfg); err != nil {
		return Manifest{}, err
	}
	if err := os.MkdirAll(rootDir, 0o755); err != nil {
		return Manifest{}, err
	}

	id, err := nextBackupID(time.Now().UTC())
	if err != nil {
		return Manifest{}, err
	}
	archiveName := id + ".tar.gz"
	archivePath := filepath.Join(rootDir, archiveName)
	serverRoot := serverDir(*cfg)
	paths := defaultPaths(cfg, serverRoot)

	backupType := strings.TrimSpace(opts.backupType)
	if backupType == "" {
		backupType = BackupTypeManual
	}

	_, running := getServerPID()
	saveOff := false
	if running {
		state.SetOperationMessage("flushing minecraft saves")
		if err := runSaveOff(); err != nil {
			return Manifest{}, fmt.Errorf("disable minecraft saves: %w", err)
		}
		saveOff = true
		defer func() {
			if saveOff {
				_ = runSaveOn()
			}
		}()
	}

	state.SetOperationMessage("creating backup archive")
	manifest := Manifest{
		ID:            id,
		CreatedAtUTC:  time.Now().UTC().Format(time.RFC3339),
		Type:          backupType,
		Reason:        strings.TrimSpace(opts.reason),
		ContainerType: cfg.ContainerType,
		Game:          cfg.Game,
		Variant:       cfg.Variant,
		Version:       cfg.Version,
		VMID:          cfg.VMID,
		Archive:       archiveName,
		Paths:         paths,
	}
	if err := writeArchive(serverRoot, archivePath, &manifest); err != nil {
		return Manifest{}, err
	}
	if saveOff {
		if err := runSaveOn(); err != nil {
			return Manifest{}, fmt.Errorf("enable minecraft saves: %w", err)
		}
		saveOff = false
	}
	if err := writeManifestSidecar(manifest); err != nil {
		return Manifest{}, err
	}
	if opts.prune {
		if err := prune(defaultMaxCount); err != nil {
			return Manifest{}, err
		}
	}
	return manifest, nil
}

func Restore(cfg *state.Config, id string) (RestoreResult, error) {
	if err := requireMinecraft(cfg); err != nil {
		return RestoreResult{}, err
	}
	id = strings.TrimSpace(id)
	if !safeID(id) {
		return RestoreResult{}, fmt.Errorf("invalid backup id")
	}

	manifest, err := readManifestSidecar(id)
	if err != nil {
		return RestoreResult{}, err
	}
	archivePath := filepath.Join(rootDir, manifest.Archive)
	if _, err := os.Stat(archivePath); err != nil {
		return RestoreResult{}, err
	}

	state.SetOperationMessage("creating pre-restore checkpoint")
	checkpoint, err := createCheckpoint(cfg)
	if err != nil {
		return RestoreResult{}, fmt.Errorf("create pre-restore checkpoint: %w", err)
	}

	if _, running := getServerPID(); running {
		state.SetOperationMessage("stopping server before restore")
		if err := stopServerAndWait(30 * time.Second); err != nil {
			return RestoreResult{}, err
		}
	}

	state.SetOperationMessage("restoring backup archive")
	if err := restoreArchive(serverDir(*cfg), archivePath, manifest.Paths); err != nil {
		return RestoreResult{}, err
	}

	state.SetOperationMessage("starting server after restore")
	state.SetState(state.StateStarting)
	state.SetReadyState(false, "", "")
	if err := startServerReady(cfg); err != nil {
		return RestoreResult{}, err
	}
	return RestoreResult{Backup: manifest, Checkpoint: checkpoint}, nil
}

func List() ([]Manifest, error) {
	entries, err := os.ReadDir(rootDir)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return []Manifest{}, nil
		}
		return nil, err
	}
	out := make([]Manifest, 0, len(entries))
	for _, entry := range entries {
		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
			continue
		}
		manifest, err := readManifestSidecar(strings.TrimSuffix(entry.Name(), ".json"))
		if err == nil {
			out = append(out, manifest)
		}
	}
	sort.Slice(out, func(i, j int) bool {
		return out[i].CreatedAtUTC > out[j].CreatedAtUTC
	})
	return out, nil
}

func Delete(id string) error {
	id = strings.TrimSpace(id)
	if !safeID(id) {
		return fmt.Errorf("invalid backup id")
	}

	archiveName := id + ".tar.gz"
	if manifest, err := readManifestSidecar(id); err == nil && strings.TrimSpace(manifest.Archive) != "" {
		archiveName = filepath.Base(manifest.Archive)
	}

	archivePath := filepath.Join(rootDir, archiveName)
	manifestPath := filepath.Join(rootDir, id+".json")

	archiveErr := os.Remove(archivePath)
	if archiveErr != nil && !errors.Is(archiveErr, os.ErrNotExist) {
		return archiveErr
	}
	manifestErr := os.Remove(manifestPath)
	if manifestErr != nil && !errors.Is(manifestErr, os.ErrNotExist) {
		return manifestErr
	}
	if errors.Is(archiveErr, os.ErrNotExist) && errors.Is(manifestErr, os.ErrNotExist) {
		return os.ErrNotExist
	}
	return nil
}

func requireMinecraft(cfg *state.Config) error {
	if cfg == nil {
		return fmt.Errorf("config required")
	}
	if !strings.EqualFold(cfg.ContainerType, "game") {
		return fmt.Errorf("backups are only available for game containers")
	}
	if !strings.EqualFold(cfg.Game, "minecraft") {
		return fmt.Errorf("backups are only implemented for minecraft")
	}
	return nil
}

func defaultPaths(cfg *state.Config, serverRoot string) []string {
	candidates := []string{}
	world := strings.TrimSpace(cfg.World)
	if world == "" {
		world = "world"
	}
	candidates = append(candidates, world)
	candidates = append(candidates,
		"server.properties",
		"whitelist.json",
		"ops.json",
		"banned-players.json",
		"banned-ips.json",
		"config",
	)

	paths := make([]string, 0, len(candidates))
	seen := map[string]struct{}{}
	for _, rel := range candidates {
		rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
		if rel == "." || rel == "" || strings.HasPrefix(rel, "../") || filepath.IsAbs(rel) {
			continue
		}
		if _, ok := seen[rel]; ok {
			continue
		}
		if _, err := os.Stat(filepath.Join(serverRoot, filepath.FromSlash(rel))); err == nil {
			paths = append(paths, rel)
			seen[rel] = struct{}{}
		}
	}
	return paths
}

func writeArchive(serverRoot, archivePath string, manifest *Manifest) error {
	file, err := os.Create(archivePath)
	if err != nil {
		return err
	}
	defer file.Close()

	gz := gzip.NewWriter(file)
	defer gz.Close()
	tw := tar.NewWriter(gz)
	defer tw.Close()

	for _, rel := range manifest.Paths {
		if err := addPath(tw, serverRoot, rel, manifest); err != nil {
			return err
		}
	}

	data, err := json.MarshalIndent(manifest, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	header := &tar.Header{
		Name:    manifestName,
		Mode:    0o644,
		Size:    int64(len(data)),
		ModTime: time.Now(),
	}
	if err := tw.WriteHeader(header); err != nil {
		return err
	}
	_, err = tw.Write(data)
	return err
}

func addPath(tw *tar.Writer, serverRoot, rel string, manifest *Manifest) error {
	abs := filepath.Join(serverRoot, filepath.FromSlash(rel))
	return filepath.WalkDir(abs, func(path string, d os.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		if info.Mode()&os.ModeSymlink != 0 {
			return nil
		}
		name, err := filepath.Rel(serverRoot, path)
		if err != nil {
			return err
		}
		name = filepath.ToSlash(name)
		if name == "." || strings.HasPrefix(name, ".zlh-shadow/") || name == ".zlh-shadow" {
			return nil
		}

		header, err := tar.FileInfoHeader(info, "")
		if err != nil {
			return err
		}
		header.Name = name
		if err := tw.WriteHeader(header); err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		f, err := os.Open(path)
		if err != nil {
			return err
		}
		n, err := io.Copy(tw, f)
		closeErr := f.Close()
		if err != nil {
			return err
		}
		if closeErr != nil {
			return closeErr
		}
		manifest.FileCount++
		manifest.TotalBytes += n
		return nil
	})
}

func restoreArchive(serverRoot, archivePath string, paths []string) error {
	for _, rel := range paths {
		if !safeRel(rel) {
			return fmt.Errorf("backup contains unsafe path: %s", rel)
		}
		if err := os.RemoveAll(filepath.Join(serverRoot, filepath.FromSlash(rel))); err != nil {
			return err
		}
	}

	file, err := os.Open(archivePath)
	if err != nil {
		return err
	}
	defer file.Close()
	gz, err := gzip.NewReader(file)
	if err != nil {
		return err
	}
	defer gz.Close()
	tr := tar.NewReader(gz)

	for {
		header, err := tr.Next()
		if errors.Is(err, io.EOF) {
			return nil
		}
		if err != nil {
			return err
		}
		if header.Name == manifestName {
			continue
		}
		if !safeRel(header.Name) {
			return fmt.Errorf("archive contains unsafe path: %s", header.Name)
		}
		if !selectedPath(header.Name, paths) {
			return fmt.Errorf("archive contains unexpected path: %s", header.Name)
		}
		target := filepath.Join(serverRoot, filepath.FromSlash(header.Name))
		switch header.Typeflag {
		case tar.TypeDir:
			if err := os.MkdirAll(target, os.FileMode(header.Mode)&0o777); err != nil {
				return err
			}
		case tar.TypeReg:
			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
				return err
			}
			out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&0o777)
			if err != nil {
				return err
			}
			if _, err := io.Copy(out, tr); err != nil {
				out.Close()
				return err
			}
			if err := out.Close(); err != nil {
				return err
			}
		default:
			return fmt.Errorf("unsupported archive entry type for %s", header.Name)
		}
	}
}

func writeManifestSidecar(manifest Manifest) error {
	data, err := json.MarshalIndent(manifest, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	return os.WriteFile(filepath.Join(rootDir, manifest.ID+".json"), data, 0o644)
}

func nextBackupID(now time.Time) (string, error) {
	base := now.UTC().Format("20060102T150405Z")
	for i := 0; i < 1000; i++ {
		id := base
		if i > 0 {
			id = fmt.Sprintf("%s_%03d", base, i)
		}
		if _, err := os.Stat(filepath.Join(rootDir, id+".json")); err == nil {
			continue
		} else if !errors.Is(err, os.ErrNotExist) {
			return "", err
		}
		if _, err := os.Stat(filepath.Join(rootDir, id+".tar.gz")); err == nil {
			continue
		} else if !errors.Is(err, os.ErrNotExist) {
			return "", err
		}
		return id, nil
	}
	return "", fmt.Errorf("unable to allocate backup id")
}

func readManifestSidecar(id string) (Manifest, error) {
	if !safeID(id) {
		return Manifest{}, fmt.Errorf("invalid backup id")
	}
	data, err := os.ReadFile(filepath.Join(rootDir, id+".json"))
	if err != nil {
		return Manifest{}, err
	}
	var manifest Manifest
	if err := json.Unmarshal(data, &manifest); err != nil {
		return Manifest{}, err
	}
	if manifest.ID != id {
		return Manifest{}, fmt.Errorf("backup manifest id mismatch")
	}
	if strings.TrimSpace(manifest.Type) == "" {
		manifest.Type = BackupTypeManual
	}
	return manifest, nil
}

func prune(maxCount int) error {
	if maxCount <= 0 {
		return nil
	}
	backups, err := List()
	if err != nil {
		return err
	}
	for i := maxCount; i < len(backups); i++ {
		_ = os.Remove(filepath.Join(rootDir, backups[i].Archive))
		_ = os.Remove(filepath.Join(rootDir, backups[i].ID+".json"))
	}
	return nil
}

func safeID(id string) bool {
	if id == "" {
		return false
	}
	for _, r := range id {
		if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == 'T' || r == 'Z' || r == '-' || r == '_' {
			continue
		}
		return false
	}
	return true
}

func safeRel(rel string) bool {
	rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
	return rel != "" && rel != "." && !filepath.IsAbs(rel) && rel != ".." && !strings.HasPrefix(rel, "../")
}

func selectedPath(name string, roots []string) bool {
	name = filepath.ToSlash(filepath.Clean(name))
	for _, root := range roots {
		root = filepath.ToSlash(filepath.Clean(root))
		if name == root || strings.HasPrefix(name, root+"/") {
			return true
		}
	}
	return false
}
