package selfupdate

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"sync"
	"testing"

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

// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error.
type fakeWrapper struct {
	mu    sync.Mutex
	calls [][]string
	err   error
}

func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls = append(f.calls, append([]string{name}, args...))
	return []byte("ok"), nil, f.err
}
func (f *fakeWrapper) applyCalls() [][]string {
	f.mu.Lock()
	defer f.mu.Unlock()
	var out [][]string
	for _, c := range f.calls {
		if len(c) >= 2 && c[1] == "apply" {
			out = append(out, c)
		}
	}
	return out
}

func sha256Of(b []byte) string {
	h := sha256.Sum256(b)
	return hex.EncodeToString(h[:])
}

// artifactServer serves `body` at /…/{version}/felhom-agent.
func artifactServer(t *testing.T, body []byte) *httptest.Server {
	t.Helper()
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write(body)
	}))
}

func newExec(t *testing.T, srv *httptest.Server, wrap WrapperRunner) (*Executor, string) {
	t.Helper()
	stateDir := t.TempDir()
	e := NewExecutor(Config{
		URLTemplate: srv.URL + "/{version}/felhom-agent",
		StateDir:    stateDir,
		Runner:      wrap,
		Logger:      slog.New(slog.NewTextHandler(io.Discard, nil)),
	})
	return e, stateDir
}

func updateParamsJSON(t *testing.T, version, sha string) json.RawMessage {
	t.Helper()
	b, _ := json.Marshal(map[string]string{"version": version, "sha256": sha})
	return b
}

// Scenario A (executor half): a good download whose sha matches the signed value is staged and
// handed to `apply` with the EXACT staged path + sha.
func TestExecutor_HappyPath(t *testing.T) {
	body := []byte("#!/bin/sh\necho v0.70.1\n")
	srv := artifactServer(t, body)
	defer srv.Close()
	wrap := &fakeWrapper{}
	e, stateDir := newExec(t, srv, wrap)

	sha := sha256Of(body)
	if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
		t.Fatalf("execute: %v", err)
	}
	staged := filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")
	got, err := os.ReadFile(staged)
	if err != nil || sha256Of(got) != sha {
		t.Fatalf("staged binary missing/mismatch: %v", err)
	}
	calls := wrap.applyCalls()
	if len(calls) != 1 {
		t.Fatalf("apply invoked %d times, want 1 (%v)", len(calls), wrap.calls)
	}
	if calls[0][2] != staged || calls[0][3] != sha {
		t.Errorf("apply args = %v, want [.. apply %s %s]", calls[0], staged, sha)
	}
}

// Scenario C2 + its companion: a download whose sha != the signed value is REFUSED, nothing is
// handed to apply, and the staged file is removed. The companion (dropping the Go-side verify) is
// structural: the sha check IS the code under test — if it were removed, this bad binary would
// reach the apply call (asserted here: applyCalls == 0).
func TestExecutor_ShaMismatchRefused(t *testing.T) {
	body := []byte("the REAL published bytes")
	srv := artifactServer(t, body)
	defer srv.Close()
	wrap := &fakeWrapper{}
	e, stateDir := newExec(t, srv, wrap)

	wrongSha := sha256Of([]byte("what the operator signed for a DIFFERENT binary"))
	err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", wrongSha))
	if err == nil {
		t.Fatal("expected sha-mismatch refusal, got nil")
	}
	if len(wrap.applyCalls()) != 0 {
		t.Error("a sha-mismatched binary REACHED the apply call — the verify gate leaked")
	}
	if _, statErr := os.Stat(filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")); !os.IsNotExist(statErr) {
		t.Error("staged file was left behind after a sha mismatch")
	}
}

// Bad params / non-semver version / non-hex sha are refused before any download or apply.
func TestExecutor_BadParamsRefused(t *testing.T) {
	wrap := &fakeWrapper{}
	e, _ := newExec(t, artifactServer(t, []byte("x")), wrap)
	for name, p := range map[string]json.RawMessage{
		"non-semver version": updateParamsJSON(t, "latest", sha256Of([]byte("x"))),
		"non-hex sha":        updateParamsJSON(t, "0.70.1", "NOTHEX"),
		"bad json":           json.RawMessage(`{`),
	} {
		if err := e.Execute(context.Background(), "agent_update", p); err == nil {
			t.Errorf("%s: expected refusal", name)
		}
	}
	if len(wrap.calls) != 0 {
		t.Errorf("wrapper invoked on bad params: %v", wrap.calls)
	}
}

// A non-owned op class returns ErrNoExecutor (the chain contract) — never touches anything.
func TestExecutor_NotOurOp(t *testing.T) {
	e, _ := newExec(t, artifactServer(t, []byte("x")), &fakeWrapper{})
	if err := e.Execute(context.Background(), "storage_wipe", json.RawMessage(`{}`)); !errors.Is(err, signedjobs.ErrNoExecutor) {
		t.Errorf("err = %v, want ErrNoExecutor", err)
	}
}

// A wrapper apply failure surfaces (the executor reports it — the runner then logs + clears).
func TestExecutor_WrapperFailureSurfaces(t *testing.T) {
	body := []byte("good bytes")
	srv := artifactServer(t, body)
	defer srv.Close()
	wrap := &fakeWrapper{err: errors.New("wrapper refused: sha mismatch")}
	e, _ := newExec(t, srv, wrap)
	if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
		t.Fatal("wrapper failure must surface")
	}
}
