package stacks

import (
	"io"
	"log"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"
)

// R-47 (v0.153.0) — the two seams the restore paths need in order to replay a DB dump without the
// application racing it: a scoped bring-up, and a persist-without-start.

// newR47Manager builds a Manager with one stack whose .felhom.yml declares a locked data-key and a
// plain field, so the persist half's locked-field and encryption behaviour is observable.
func newR47Manager(t *testing.T) (*Manager, string) {
	t.Helper()
	stackDir := filepath.Join(t.TempDir(), "app")
	if err := os.MkdirAll(stackDir, 0o755); err != nil {
		t.Fatal(err)
	}
	meta := `display_name: App
deploy_fields:
  - env_var: SECRET_KEY
    type: secret
    locked_after_deploy: true
  - env_var: SUBDOMAIN
    type: subdomain
  - env_var: TIMEZONE
    type: text
`
	if err := os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
		t.Fatal(err)
	}
	m := &Manager{
		logger: log.New(io.Discard, "", 0),
		encKey: []byte("0123456789abcdef0123456789abcdef"), // 32 bytes → AES-256
		stacks: map[string]*Stack{
			"app": {Name: "app", ComposePath: filepath.Join(stackDir, "docker-compose.yml")},
		},
	}
	return m, stackDir
}

// TestStartStackServicesRefusesEmptyList is the whole reason this function is not a thin wrapper.
// `docker compose up -d` with no service arguments is a FULL start — the exact behaviour the DB-only
// window exists to avoid. So an empty list must be an ERROR, never a silent pass-through: a caller
// that computed zero DB services has, by definition, nothing it may safely start.
//
// The refusal is also asserted to happen WITHOUT reaching compose: it fires for an unknown stack
// too, which proves nothing was executed (a real `up` would need a stack dir and a docker daemon).
func TestStartStackServicesRefusesEmptyList(t *testing.T) {
	m, _ := newR47Manager(t)

	for _, svcs := range [][]string{nil, {}} {
		err := m.StartStackServices("app", svcs)
		if err == nil {
			t.Fatalf("an empty service list (%v) must be refused — argument-less `up -d` is a FULL start", svcs)
		}
		if !strings.Contains(err.Error(), "empty service list") {
			t.Fatalf("refusal must name the cause, got: %v", err)
		}
	}
	// Unknown stack: refused at the lookup, still without touching compose.
	if err := m.StartStackServices("nope", []string{"db"}); err == nil {
		t.Fatal("an unknown stack must be refused")
	}
}

// TestPersistUnitRedeployConfigPersistsWithoutStarting is the split's contract. RedeployFromEnv used
// to be persist+start in one call, which is why the local restore path could not put the DB-only
// window between them. This asserts the persist half is COMPLETE on its own — app.yaml written with
// the deployed marker, the locked field recorded, the secret encrypted at rest and decryptable, and
// the in-memory stack flipped to deployed — so RedeployFromEnv's public behaviour is unchanged by
// being expressed as this function plus the untouched up-and-report tail.
func TestPersistUnitRedeployConfigPersistsWithoutStarting(t *testing.T) {
	m, stackDir := newR47Manager(t)
	const secret = "s3cr3t-data-key-value"
	env := map[string]string{"SECRET_KEY": secret, "SUBDOMAIN": "app", "TIMEZONE": "Europe/Budapest", "HDD_PATH": "/mnt/drv"}

	if err := m.PersistUnitRedeployConfig("app", env); err != nil {
		t.Fatalf("PersistUnitRedeployConfig: %v", err)
	}

	cfgPath := filepath.Join(stackDir, "app.yaml")
	raw, err := os.ReadFile(cfgPath)
	if err != nil {
		t.Fatalf("app.yaml was not written — the persist half is incomplete: %v", err)
	}
	// Secrets safety: the plaintext must not be at rest in app.yaml.
	if strings.Contains(string(raw), secret) {
		t.Fatal("SECRET LEAK: the secret value is stored in plaintext in app.yaml")
	}

	got := LoadAppConfigDecrypted(stackDir, m.encKey)
	if got == nil {
		t.Fatal("app.yaml does not load back")
	}
	if !got.Deployed || got.DeployedAt == "" {
		t.Errorf("app must be marked deployed with a timestamp, got deployed=%v at=%q", got.Deployed, got.DeployedAt)
	}
	if got.Env["SECRET_KEY"] != secret {
		t.Errorf("SECRET_KEY did not round-trip through encryption, got %q", got.Env["SECRET_KEY"])
	}
	if got.Env["SUBDOMAIN"] != "app" || got.Env["HDD_PATH"] != "/mnt/drv" {
		t.Errorf("non-secret env did not persist verbatim: %v", got.Env)
	}
	// Secrets and subdomains are implicitly locked-after-deploy; a plain text field is not. Recording
	// exactly those is part of the persist half, and losing it in the split would silently unlock a
	// data-key field on the next config edit.
	if !reflect.DeepEqual(got.LockedFields, []string{"SECRET_KEY", "SUBDOMAIN"}) {
		t.Errorf("locked fields = %v, want [SECRET_KEY SUBDOMAIN] (TIMEZONE must NOT be locked)", got.LockedFields)
	}

	// In-memory state must agree, because StartStack (the caller's next step) reads it.
	s, ok := m.GetStack("app")
	if !ok || !s.Deployed {
		t.Fatalf("in-memory stack not marked deployed (ok=%v), so the follow-up start would treat it as undeployed", ok)
	}
	if s.AppConfig == nil || s.AppConfig.Env["SECRET_KEY"] == "" {
		t.Error("in-memory AppConfig not populated by the persist half")
	}
}

// TestPersistUnitRedeployConfigRejectsUnknownStack keeps the failure direction the same as the
// unsplit function's: an unknown stack is an error, not a silently-created app.yaml somewhere.
func TestPersistUnitRedeployConfigRejectsUnknownStack(t *testing.T) {
	m, _ := newR47Manager(t)
	if err := m.PersistUnitRedeployConfig("nope", map[string]string{"A": "b"}); err == nil {
		t.Fatal("an unknown stack must be refused")
	}
}
