package selfupdate

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"time"

	"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
)

// WrapperRunner shells the guarded wrapper's verbs via `sudo -n` (satisfied by *proxmox.ExecRunner
// in RunnerSudo mode). Seam so the executor + commit-manager tests never actually invoke sudo.
type WrapperRunner interface {
	Run(ctx context.Context, name string, args ...string) (stdout, stderr []byte, err error)
}

// wrapperPath is the fixed guarded-wrapper path (never config-overridable — path-fixedness is the
// security property).
const wrapperPath = "/usr/local/sbin/felhom-selfupdate-guarded"

// updateParams is the verified params of an agent_update op — the operator-pinned target.
type updateParams struct {
	Version string `json:"version"`
	SHA256  string `json:"sha256"`
}

// Executor is the agent_update signed-op consumer. Given a gate-VERIFIED agent_update op it:
//  1. builds the download URL from config + the signed version,
//  2. downloads to <StateDir>/selfupdate/felhom-agent-<version>,
//  3. verifies the download against the SIGNED sha256 (mismatch → refuse, remove, error),
//  4. hands the staged binary to `felhom-selfupdate-guarded apply <staged> <sha>` via sudo -n.
//
// The wrapper re-verifies the sha as root and performs the A/B flip + detached restart, so this
// process may die shortly after the apply call returns (the S2b detached restart usually lets it
// survive to log). Rollback is never this executor's job — a crash-looping new binary is reverted
// by systemd + the wrapper.
type Executor struct {
	urlTemplate string
	username    string
	token       string
	stateDir    string
	runner      WrapperRunner
	httpClient  *http.Client
	logger      *slog.Logger
}

// Config is the executor's dependencies (from config.SelfUpdateConfig + the sudo runner).
type Config struct {
	URLTemplate string
	Username    string
	Token       string
	StateDir    string
	Runner      WrapperRunner
	HTTPClient  *http.Client // nil → a 5-minute-timeout default
	Logger      *slog.Logger
}

// NewExecutor builds the agent_update executor.
func NewExecutor(cfg Config) *Executor {
	hc := cfg.HTTPClient
	if hc == nil {
		hc = &http.Client{Timeout: 5 * time.Minute}
	}
	return &Executor{
		urlTemplate: cfg.URLTemplate,
		username:    cfg.Username,
		token:       cfg.Token,
		stateDir:    cfg.StateDir,
		runner:      cfg.Runner,
		httpClient:  hc,
		logger:      orDefaultLogger(cfg.Logger),
	}
}

// Execute implements signedjobs.Executor for the agent_update op class.
func (e *Executor) Execute(ctx context.Context, op string, params json.RawMessage) error {
	if op != opAgentUpdate {
		return signedjobs.ErrNoExecutor // not ours — leave queued for the owning executor
	}
	var p updateParams
	if err := json.Unmarshal(params, &p); err != nil {
		return fmt.Errorf("agent_update: bad params: %w", err)
	}
	if !versionRe.MatchString(p.Version) {
		return fmt.Errorf("agent_update: refusing — version %q is not bare semver", p.Version)
	}
	if !sha256Re.MatchString(p.SHA256) {
		return fmt.Errorf("agent_update: refusing — sha256 is not 64 lowercase hex")
	}
	if e.runner == nil {
		return fmt.Errorf("agent_update: no wrapper runner configured")
	}
	e.logger.Debug("agent_update: op invariants passed (semver + sha format)", "version", p.Version)

	dir := stagingDir(e.stateDir)
	if err := os.MkdirAll(dir, 0o750); err != nil {
		return fmt.Errorf("agent_update: staging dir: %w", err)
	}
	staged := filepath.Join(dir, "felhom-agent-"+p.Version)
	url := interpolateURL(e.urlTemplate, p.Version)

	e.logger.Warn("agent_update: downloading operator-signed binary", "version", p.Version, "url", url, "sha256", p.SHA256)
	dlStart := time.Now()
	got, err := e.download(ctx, url, staged)
	if err != nil {
		_ = os.Remove(staged)
		return fmt.Errorf("agent_update: download %s: %w", url, err)
	}
	e.logger.Debug("agent_update: download complete", "version", p.Version,
		"sha_match", got == p.SHA256, "duration_ms", time.Since(dlStart).Milliseconds())
	// The signed sha is the ONLY integrity root — verify BEFORE anything touches the live binary.
	if got != p.SHA256 {
		_ = os.Remove(staged)
		return fmt.Errorf("agent_update: sha256 mismatch — got %s want %s (refusing; agent untouched)", got, p.SHA256)
	}
	if err := os.Chmod(staged, 0o755); err != nil {
		_ = os.Remove(staged)
		return fmt.Errorf("agent_update: chmod staged: %w", err)
	}

	// Hand off to the root wrapper. It re-verifies the sha, flips A/B, writes the pending marker,
	// and schedules the detached restart. After this the new binary starts; the commit is the
	// Manager's job once it has dwelled cleanly.
	e.logger.Warn("agent_update: handing staged binary to the guarded wrapper", "staged", staged, "version", p.Version)
	stdout, stderr, err := e.runner.Run(ctx, wrapperPath, "apply", staged, p.SHA256)
	if err != nil {
		return fmt.Errorf("agent_update: wrapper apply failed: %w (stderr: %s)", err, string(stderr))
	}
	e.logger.Warn("agent_update: apply handed off; restart scheduled", "version", p.Version, "wrapper", trim(stdout))
	return nil
}

// download streams url → dest (0644, fsync'd) and returns the lowercase-hex sha256 of the bytes.
func (e *Executor) download(ctx context.Context, url, dest string) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return "", err
	}
	if e.username != "" || e.token != "" {
		req.SetBasicAuth(e.username, e.token)
	}
	resp, err := e.httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return "", fmt.Errorf("HTTP %d", resp.StatusCode)
	}
	f, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
	if err != nil {
		return "", err
	}
	h := sha256.New()
	if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
		f.Close()
		return "", err
	}
	if err := f.Sync(); err != nil {
		f.Close()
		return "", err
	}
	if err := f.Close(); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

func trim(b []byte) string {
	s := string(b)
	if len(s) > 200 {
		s = s[:200]
	}
	return s
}
