// Package update applies a new build of Maven to the box she runs on, with a
// verified-before-committed install and an automatic rollback (Vikunja #249).
//
// # What this package refuses to be
//
// This is the highest-risk capability in the backlog — code that changes the
// running system — so the refusals are as much of the design as the features,
// and they are enforced here rather than described in a doc:
//
//   - It is never automatic and never on a timer. There is no checker, no
//     channel, no "check for updates" call and nothing that fires from the tick
//     loop. Apply runs exactly when a human runs cmd/mavupdate on the box.
//   - The daemon cannot update itself. mavend never constructs an Updater and
//     nothing in the daemon can call Apply: there is no IPC method and no web
//     route that reaches this package, so no act, no intent, no tool and no LLM
//     output can start an update. (The package IS linked into mavend, via
//     internal/config, which calls Config.Validate so a bad update block is
//     caught at daemon startup rather than on the night it is needed. Linked is
//     not reachable — the guarantee is the absent caller, not an absent
//     import.) The trigger needs shell access to the host, which is a strictly
//     higher bar than the step-up passkey gate that guards /tools — an update
//     is not a thing to expose to anything reachable over the network.
//   - It does not fetch code. Nothing here talks to a release server, a
//     registry, or GitHub. The new version is whatever is in the working tree
//     the operator points it at, which he pulled himself. Downloading and
//     running code on the strength of a checksum in the same download is not a
//     property we can verify on one box.
//   - It does not supervise its own death. The plan asked for an in-process
//     crash-loop detector; a process cannot reliably notice that it keeps
//     dying, and one that thinks it can is worse than nothing. Restart-on-crash
//     belongs to whatever starts mavend (compose `restart: unless-stopped`,
//     systemd `Restart=`). What this package guarantees instead is narrower and
//     real: within one Apply, the new build is proven to answer before the old
//     one is considered replaced, and if it does not answer the old bytes go
//     back and are proven to answer again.
//
// # The order of operations, and why
//
// Apply is: health-check the CURRENT daemon → snapshot → build → test →
// install → restart → health-check → rollback on any failure.
//
// The first health check is not ceremony. If she is already not answering, a
// failed update and a broken box are indistinguishable afterwards, and the
// rollback has nothing to prove itself against — so Apply refuses to start.
//
// The snapshot comes before the build, not after, and the comment in apply.go
// spells out why: `make build` writes into the working tree, which on the
// docker deployment IS the install dir, so a snapshot taken after it would
// snapshot the new artifacts.
//
// Build and test run BEFORE anything is written to the install dir, so a broken
// tree costs nothing but time. Install is per-file write-temp-then-rename, so a
// crash mid-install leaves whole files, not half ones.
//
// The rollback path deliberately depends on nothing that just changed: it copies
// byte-for-byte from a snapshot taken before the install and re-runs the same
// restart command. It does not ask the new binary to do anything, does not run
// a migration, and does not need the update to have gotten far enough to leave
// a working anything behind.
//
// # What the snapshot has to cover
//
// A rollback is only real if it puts back the thing the restart command
// deploys. For a bare-metal layout that is the binaries in InstallDir. For the
// docker layout it is not: the image is built from the source tree, and the
// host binaries never enter it. Restoring binaries there rebuilds the same bad
// image and burns a second health timeout proving it. So a deployment that
// rebuilds from source must say how the source is put back
// (Config.SourceRollback), and one that cannot say is refused by Validate
// rather than discovering it during the one rollback that mattered.
//
// # What an update is not
//
// It is not turn-safe. Nothing quiesces the daemon first: the restart kills the
// process mid-utterance if one is in flight. The model swap in
// internal/phraser drains, because a swap is a routine operation on a running
// box; an update is a deliberate restart and the operator picked the moment.
//
// # What is out of scope on purpose
//
// The database is not snapshotted or rolled back. It is encrypted, live, and
// often larger than the disk headroom; a store rolled back under a schema that
// already migrated forward loses writes silently, which is worse than a failed
// update. Schema compatibility is store.Migrate's job. A snapshot here is the
// deployable artifacts only: binaries and config.
package update

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

var (
	// ErrNotConfigured — no update block in the config. The capability does not
	// exist unless the operator described his own deployment.
	ErrNotConfigured = errors.New("update: not configured")

	// ErrUnhealthyBefore — the daemon was already not answering when Apply
	// started. Refused: see the package comment.
	ErrUnhealthyBefore = errors.New("update: the running daemon is not healthy — refusing to update on top of a broken box")

	// ErrVerifyFailed — build or test failed. Nothing was installed.
	ErrVerifyFailed = errors.New("update: verification failed")

	// ErrRolledBack — the new build was installed and did not come up healthy,
	// so the previous snapshot was restored. Wraps the underlying failure.
	ErrRolledBack = errors.New("update: rolled back")

	// ErrSourceRollback — the deployment rebuilds from source, and nothing in
	// the config says how to put the source back. Refused at Validate: see
	// Config.SourceRollback.
	ErrSourceRollback = errors.New("update: this deployment rebuilds from source and has no way to roll the source back")

	// ErrDirtyTree — source_rollback is "git" and the working tree has
	// uncommitted changes, so the recorded commit does not describe what is
	// deployed and a checkout would throw work away. Refused before anything is
	// built.
	ErrDirtyTree = errors.New("update: the source tree has uncommitted changes — commit or stash them first")

	// ErrRootOnHisTree — running as root over a tree owned by somebody else.
	// Refused: Verify runs `make build` and `make test` in SourceDir, and as
	// root that leaves root-owned binaries, object files and a build cache in
	// his working tree. His next ordinary `make` then fails, so one root apply
	// breaks the normal build. This fires easily, because the documented health
	// socket lives under a root-only directory and sudo is the obvious way past
	// that.
	ErrRootOnHisTree = errors.New("update: refusing to build someone else's tree as root — it would leave root-owned artifacts and break his next make")

	// ErrRollbackFailed — the worst case: the new build failed AND the restore
	// did not bring her back. The operator has to fix the box by hand; the
	// snapshot directory is named in the result so he knows what to copy.
	ErrRollbackFailed = errors.New("update: ROLLBACK FAILED — manual recovery required")
)

// Config — the operator's description of his own deployment. Every path is
// absolute and validated; nothing is guessed, because guessing wrong here means
// overwriting the wrong file.
type Config struct {
	// SourceDir — the git working tree to build. The operator pulls it himself;
	// this package never fetches.
	SourceDir string `json:"source_dir"`

	// InstallDir — where the built binaries are copied to. On the docker
	// deployment this is the tree the image is built from, so it is usually the
	// same as SourceDir and Install is a no-op copy; on a bare-metal deployment
	// it is /opt/maven/bin.
	InstallDir string `json:"install_dir"`

	// SnapshotDir — where the pre-install copies live. Must not be inside
	// InstallDir or SourceDir: a restore reading from a directory the install is
	// writing to is not a restore, and a snapshot dir inside the source tree
	// lands in the docker build context and in whatever make and git do there.
	SnapshotDir string `json:"snapshot_dir"`

	// SourceRollback — how the SOURCE is put back when the deployment rebuilds
	// from it. "" means it is not, which is only valid when the built binaries
	// are what gets deployed.
	//
	// This exists because of what a rollback has to undo, which is not always
	// the binaries. When RestartCmd is `docker compose up -d --build`, the image
	// is built by the Dockerfile from cmd/ and internal/, and the host binaries
	// are excluded by .dockerignore. Restoring them then restores bytes nothing
	// reads: the restart rebuilds the same bad image from the same bad source,
	// and the box stays down through two health timeouts for no reason.
	//
	// "git" makes the source part of the snapshot: the commit is recorded before
	// the update and a rollback checks it back out before restarting. It
	// requires a clean tree, because a recorded commit does not describe a dirty
	// one and a forced checkout would throw uncommitted work away.
	//
	// Validate refuses a build-from-source deployment (SourceDir == InstallDir)
	// that leaves this empty, rather than letting the operator find out during
	// the one rollback he needed.
	SourceRollback string `json:"source_rollback,omitempty"`

	// Binaries — the artifact names to snapshot and install, relative to
	// SourceDir (built) and InstallDir (deployed). Listed explicitly rather than
	// globbed so a stray file in the tree never gets deployed.
	Binaries []string `json:"binaries"`

	// ConfigFiles — extra files to snapshot alongside the binaries, relative to
	// InstallDir. Snapshotted and never written back: not by an install, and
	// not by a rollback either. The operator's config is not something an update
	// gets to replace, and a rollback that reverted it would silently undo every
	// edit since the last apply. The copies are in the snapshot dir if he wants
	// one back.
	//
	// The exception is source_rollback "git": a checkout moves every tracked
	// file, config included. That is the same rollback the deployment needs to
	// work at all, so on that shape a config edit belongs in a commit.
	ConfigFiles []string `json:"config_files,omitempty"`

	// RestartCmd — how this deployment restarts mavend, e.g.
	// ["docker","compose","up","-d","--build","mavend"] or
	// ["systemctl","restart","mavend"]. Run in SourceDir. Required: there is no
	// portable default and picking one would mean restarting the wrong thing.
	RestartCmd []string `json:"restart_cmd"`

	// HealthSocket — mavend's IPC socket, used to prove she answers after a
	// restart. Required: without a health check there is no signal to roll back
	// on, and an update that cannot detect its own failure is not what this
	// package is for.
	HealthSocket string `json:"health_socket"`

	// HealthTimeoutSec — how long to wait for the restarted daemon to answer.
	// Default 90s; she loads a 1.7B on boot, so this is not a couple of seconds.
	HealthTimeoutSec int `json:"health_timeout_sec,omitempty"`

	// VerifyTimeoutMin — cap on `make build` + `make test`. Default 20m.
	VerifyTimeoutMin int `json:"verify_timeout_min,omitempty"`

	// KeepSnapshots — how many snapshots to retain. Default 5, minimum 1: the
	// most recent one is the rollback target and is never pruned.
	KeepSnapshots int `json:"keep_snapshots,omitempty"`
}

// Validate — fail at startup, not halfway through an install.
func (c Config) Validate() error {
	if c.SourceDir == "" || c.InstallDir == "" || c.SnapshotDir == "" {
		return errors.New("update: source_dir, install_dir and snapshot_dir are all required")
	}
	for _, p := range []string{c.SourceDir, c.InstallDir, c.SnapshotDir} {
		if !filepath.IsAbs(p) {
			return fmt.Errorf("update: %q must be an absolute path", p)
		}
	}
	if within(c.SnapshotDir, c.InstallDir) {
		return fmt.Errorf("update: snapshot_dir %q is inside install_dir %q — a restore must not read from what the install writes", c.SnapshotDir, c.InstallDir)
	}
	if within(c.SnapshotDir, c.SourceDir) {
		return fmt.Errorf("update: snapshot_dir %q is inside source_dir %q — snapshots would land in the build context, and in whatever make and git do to that tree", c.SnapshotDir, c.SourceDir)
	}
	switch c.SourceRollback {
	case "", "git":
	default:
		return fmt.Errorf("update: source_rollback %q is not a thing — use \"git\" or leave it out", c.SourceRollback)
	}
	if c.buildsFromSource() && c.SourceRollback == "" {
		return fmt.Errorf("%w: source_dir and install_dir are both %q, so the restart deploys the tree and a restore of the binaries would undo nothing. Set \"source_rollback\": \"git\", or split the layout so install_dir holds what actually runs", ErrSourceRollback, c.SourceDir)
	}
	if len(c.Binaries) == 0 {
		return errors.New("update: binaries is empty — nothing to install")
	}
	for _, b := range append(append([]string{}, c.Binaries...), c.ConfigFiles...) {
		if filepath.IsAbs(b) || strings.Contains(b, "..") {
			return fmt.Errorf("update: %q must be a plain relative name", b)
		}
	}
	if len(c.RestartCmd) == 0 {
		return errors.New("update: restart_cmd is required — there is no safe default for restarting someone else's deployment")
	}
	if c.HealthSocket == "" {
		return errors.New("update: health_socket is required — an update that cannot check its own result cannot roll back on failure")
	}
	return nil
}

func (c Config) withDefaults() Config {
	if c.HealthTimeoutSec <= 0 {
		c.HealthTimeoutSec = 90
	}
	if c.VerifyTimeoutMin <= 0 {
		c.VerifyTimeoutMin = 20
	}
	if c.KeepSnapshots < 1 {
		c.KeepSnapshots = 5
	}
	return c
}

// buildsFromSource — the deployment whose restart command rebuilds from the
// tree, which is what SourceDir == InstallDir means in practice (install is a
// no-op copy and the artifacts that matter are produced inside the image).
func (c Config) buildsFromSource() bool {
	return filepath.Clean(c.SourceDir) == filepath.Clean(c.InstallDir)
}

func (c Config) healthTimeout() time.Duration {
	return time.Duration(c.HealthTimeoutSec) * time.Second
}

func (c Config) verifyTimeout() time.Duration {
	return time.Duration(c.VerifyTimeoutMin) * time.Minute
}

// within reports whether p is dir or lives under it.
func within(p, dir string) bool {
	p, dir = filepath.Clean(p), filepath.Clean(dir)
	if p == dir {
		return true
	}
	rel, err := filepath.Rel(dir, p)
	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

// Runner runs one command and returns its combined output. Injected so the
// tests can drive build/test/restart failures without a toolchain, a container
// or a real daemon to break.
type Runner func(ctx context.Context, dir string, argv []string) (string, error)

// ExecRunner is the real one.
func ExecRunner(ctx context.Context, dir string, argv []string) (string, error) {
	cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	return string(out), err
}

// HealthCheck proves the daemon at socket answers. Injected for the same reason
// as Runner.
type HealthCheck func(ctx context.Context, socket string) error

// Logger receives one line per step. The CLI prints these as they happen: an
// update that goes quiet for four minutes during `make test` reads as a hang.
type Logger func(format string, args ...any)

// Updater is the whole capability. Construct with New and call Apply or
// Rollback; there is no background goroutine and nothing starts on its own.
type Updater struct {
	cfg    Config
	store  *Store
	run    Runner
	health HealthCheck
	log    Logger
	now    func() time.Time
	// ids reports the running euid and the owner of a directory. Injected so
	// the root-build refusal is testable without a second account.
	ids func(dir string) (int, uint32, error)
}

// New builds an Updater. Every seam has a real default; the tests replace them.
func New(cfg Config, opts ...Option) (*Updater, error) {
	if err := cfg.Validate(); err != nil {
		return nil, err
	}
	u := &Updater{
		cfg:    cfg.withDefaults(),
		store:  &Store{Dir: cfg.SnapshotDir},
		run:    ExecRunner,
		health: DialHealth,
		log:    func(string, ...any) {},
		now:    time.Now,
		ids:    realIDs,
	}
	for _, o := range opts {
		o(u)
	}
	u.store.now = u.now
	return u, nil
}

// Option — a constructor seam.
type Option func(*Updater)

func WithRunner(r Runner) Option      { return func(u *Updater) { u.run = r } }
func WithHealth(h HealthCheck) Option { return func(u *Updater) { u.health = h } }
func WithLogger(l Logger) Option      { return func(u *Updater) { u.log = l } }
func WithClock(f func() time.Time) Option {
	return func(u *Updater) { u.now = f }
}

// WithIDs replaces the euid/owner lookup behind the root-build refusal.
func WithIDs(f func(dir string) (int, uint32, error)) Option {
	return func(u *Updater) { u.ids = f }
}

// Snapshots lists what is available to roll back to, newest first.
func (u *Updater) Snapshots() ([]Snapshot, error) { return u.store.List() }
