package updater

import (
	"errors"
	"fmt"
	"os"

	"banger/internal/system"
)

// previousSuffix is the filename suffix appended to the
// pre-swap binary so Rollback knows where to restore from.
// Pinned as a constant so the swap and rollback paths can't
// disagree on it.
const previousSuffix = ".previous"

// InstallTargets lists the absolute on-disk paths the updater
// writes during a swap. Hardcoded to the system-install layout —
// banger update is a system-mode operation; the developer non-
// system-mode flow doesn't go through this code path.
type InstallTargets struct {
	Banger     string // /usr/local/bin/banger
	Bangerd    string // /usr/local/bin/bangerd
	VsockAgent string // /usr/local/lib/banger/banger-vsock-agent
}

// DefaultInstallTargets returns the canonical paths a system install
// uses (`banger system install` writes to these). Exposed for
// testability; production callers use it as-is.
func DefaultInstallTargets() InstallTargets {
	return InstallTargets{
		Banger:     "/usr/local/bin/banger",
		Bangerd:    "/usr/local/bin/bangerd",
		VsockAgent: "/usr/local/lib/banger/banger-vsock-agent",
	}
}

// SwapResult records what was swapped, so Rollback knows what to
// undo. A nil SwapResult means no swap was attempted yet (nothing
// to roll back).
type SwapResult struct {
	Targets InstallTargets
	// SwappedTargets is the subset of Targets that were actually
	// renamed into place. If the second of three Renames fails,
	// SwappedTargets contains only the first; rollback unwinds in
	// reverse order.
	SwappedTargets []string
}

// Swap atomically replaces each of the three banger binaries with
// its staged counterpart. Order:
//
//  1. banger-vsock-agent  (companion; not currently running, swap is safe)
//  2. bangerd              (the to-be-restarted daemon binary)
//  3. banger                (the CLI; least disruptive last)
//
// Each AtomicReplace leaves a `.previous` backup so Rollback can
// restore the prior install if a later step fails.
//
// Returns the SwapResult even on partial failure so the caller can
// drive Rollback against what HAS been swapped.
func Swap(staged StagedRelease, targets InstallTargets) (SwapResult, error) {
	res := SwapResult{Targets: targets}
	steps := []struct {
		src, dst string
	}{
		{src: staged.VsockAgentPath, dst: targets.VsockAgent},
		{src: staged.BangerdPath, dst: targets.Bangerd},
		{src: staged.BangerPath, dst: targets.Banger},
	}
	for _, s := range steps {
		if err := ensureParentDir(s.dst); err != nil {
			return res, fmt.Errorf("prepare %s: %w", s.dst, err)
		}
		if err := system.AtomicReplace(s.src, s.dst, previousSuffix); err != nil {
			return res, fmt.Errorf("swap %s: %w", s.dst, err)
		}
		res.SwappedTargets = append(res.SwappedTargets, s.dst)
	}
	return res, nil
}

// Rollback undoes a Swap by restoring each .previous backup in
// reverse order. Returns the joined errors of every individual
// rollback that failed; a half-rolled-back tree is the worst case
// and the operator gets enough information to fix it manually.
//
// Tolerant of partial input — passing a SwapResult that only
// recorded the first two of three swaps rolls back exactly those
// two.
func Rollback(res SwapResult) error {
	var errs []error
	for i := len(res.SwappedTargets) - 1; i >= 0; i-- {
		dst := res.SwappedTargets[i]
		if err := system.AtomicReplaceRollback(dst, previousSuffix); err != nil {
			errs = append(errs, fmt.Errorf("rollback %s: %w", dst, err))
		}
	}
	return errors.Join(errs...)
}

// CleanupBackups removes every .previous backup left behind by a
// successful update. Called after `banger doctor` confirms the new
// install is healthy — we don't keep ancient backups around forever.
func CleanupBackups(res SwapResult) error {
	var errs []error
	for _, dst := range res.SwappedTargets {
		if err := os.Remove(dst + previousSuffix); err != nil && !os.IsNotExist(err) {
			errs = append(errs, fmt.Errorf("remove %s%s: %w", dst, previousSuffix, err))
		}
	}
	return errors.Join(errs...)
}

func ensureParentDir(p string) error {
	parent := dirOf(p)
	if parent == "" {
		return nil
	}
	if _, err := os.Stat(parent); err == nil {
		return nil
	}
	return os.MkdirAll(parent, 0o755)
}

// dirOf is a tiny path.Dir wrapper that returns "" for paths with
// no separator (so the ensure-parent logic doesn't try to mkdir(".")).
func dirOf(p string) string {
	for i := len(p) - 1; i >= 0; i-- {
		if p[i] == '/' {
			return p[:i]
		}
	}
	return ""
}
