package appbackup

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

// R-47 (v0.153.0) — the DB-service resolver.
//
// These exist because a dump replay that starts the WHOLE stack races the application's own schema
// management: proven live on 2026-07-19 (DIAG-immich-restore-round2-2026-07-19, H4) when
// immich-server rebuilt `clip_index` two seconds before the dump's CREATE INDEX and the replay
// aborted `already exists`. Closing that window means bringing up ONLY the database service, which
// means naming it correctly — every case below is a way of naming it wrongly.

// writeCompose drops a compose file in a temp dir and returns its path.
func writeCompose(t *testing.T, body string) string {
	t.Helper()
	p := filepath.Join(t.TempDir(), "docker-compose.yml")
	if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	return p
}

// TestDBTypeForImage pins the shared heuristic. It is the SAME predicate DiscoverDatabases applies to
// a running container's image, which is what makes "a dump exists ⇒ a service can be named" hold:
// the compose `image:` value IS the container's image string. The table reproduces the inline form
// this function replaced, byte for byte, including the redis/valkey negatives that must never be
// started in the DB-only window.
func TestDBTypeForImage(t *testing.T) {
	cases := []struct {
		image string
		want  DBType
		ok    bool
	}{
		{"docker.io/library/postgres:16-alpine", DBTypePostgres, true},
		// immich's real pin — a vector-extended postgres whose REPO segment carries the substring.
		{"ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0", DBTypePostgres, true},
		{"postgres", DBTypePostgres, true},
		{"POSTGRES:16", DBTypePostgres, true}, // the discovery path lowercases; so does this
		{"mariadb:11", DBTypeMariaDB, true},
		{"mysql:8.4", DBTypeMariaDB, true},
		{"docker.io/library/MySQL:8", DBTypeMariaDB, true},
		{"redis:7-alpine", "", false},
		{"valkey/valkey:8", "", false},
		{"ghcr.io/immich-app/immich-server:v1.119.0", "", false},
		{"", "", false},
	}
	for _, c := range cases {
		got, ok := dbTypeForImage(c.image)
		if ok != c.ok || (ok && got != c.want) {
			t.Errorf("dbTypeForImage(%q) = (%q, %v), want (%q, %v)", c.image, got, ok, c.want, c.ok)
		}
	}
}

func TestDBServiceNames(t *testing.T) {
	cases := []struct {
		name string
		body string
		want []string
	}{
		{
			name: "postgres service is named",
			body: "services:\n  app:\n    image: ghcr.io/x/app:1\n  database:\n    image: postgres:16\n",
			want: []string{"database"},
		},
		{
			name: "mariadb service is named",
			body: "services:\n  db:\n    image: mariadb:11\n  web:\n    image: nextcloud:30\n",
			want: []string{"db"},
		},
		{
			name: "mysql service is named",
			body: "services:\n  mysql:\n    image: mysql:8.4\n",
			want: []string{"mysql"},
		},
		{
			name: "redis-only app has no database service",
			body: "services:\n  app:\n    image: ghcr.io/x/app:1\n  redis:\n    image: redis:7-alpine\n",
			want: nil,
		},
		{
			name: "multiple databases are returned SORTED (one up -d carries them all)",
			body: "services:\n  zdb:\n    image: postgres:16\n  adb:\n    image: mariadb:11\n  app:\n    image: x:1\n",
			want: []string{"adb", "zdb"},
		},
		{
			name: "no services key at all",
			body: "volumes:\n  data:\n",
			want: nil,
		},
		{
			name: "empty services map",
			body: "services:\n",
			want: nil,
		},
		{
			name: "an interpolated image is not guessed at",
			body: "services:\n  db:\n    image: ${DB_IMAGE}\n",
			want: nil,
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := DBServiceNames(writeCompose(t, c.body))
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if !reflect.DeepEqual(got, c.want) {
				t.Errorf("DBServiceNames = %v, want %v", got, c.want)
			}
		})
	}
}

// TestDBServiceNames_TopLevelKeysAreNotServices is the decoy test, and the reason this is a YAML
// parse rather than a line scan. immich's real compose carries a top-level `volumes:` block whose
// entry (`immich_ml_cache:`) sits at exactly the indentation a service name does, and a top-level
// `networks:` block does the same. A scanner that collected "indented keys followed by image-ish
// lines" would either invent a service that `docker compose up -d` cannot start, or — worse — match
// the wrong one and leave the real database down while the app came up around the replay.
func TestDBServiceNames_TopLevelKeysAreNotServices(t *testing.T) {
	// The service/volume/network names and the image pins are the catalog's real immich template.
	// `immich_postgres_data` is the trap made concrete: a top-level VOLUME key whose name contains
	// "postgres" and which no `up -d` could ever start.
	body := `services:
  immich-server:
    image: ghcr.io/immich-app/immich-server:v3.0.3
  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:v3.0.3
  immich-postgres:
    image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0
  immich-redis:
    image: redis:7-alpine
volumes:
  immich_ml_cache:
  immich_postgres_data:
  immich_redis_data:
networks:
  traefik-public:
    external: true
  immich-internal:
`
	got, err := DBServiceNames(writeCompose(t, body))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !reflect.DeepEqual(got, []string{"immich-postgres"}) {
		t.Fatalf("DBServiceNames = %v, want [immich-postgres] — a top-level volume/network key was mistaken for a service", got)
	}
}

// TestDBServiceNames_UnreadableAndUnparseableError proves the fail-closed direction: "cannot tell"
// must surface as an ERROR, never as the empty (= "this app has no database") answer. The callers
// turn an empty result into a refusal only when a dump exists; if a read failure silently produced
// the same empty slice for an app with no dump, a genuinely broken compose would flow on unnoticed.
func TestDBServiceNames_UnreadableAndUnparseableError(t *testing.T) {
	if _, err := DBServiceNames(filepath.Join(t.TempDir(), "nope.yml")); err == nil {
		t.Fatal("a missing compose file must be an error, not an empty service list")
	}
	// Valid YAML scalar where a map is required, plus outright broken YAML.
	if _, err := DBServiceNames(writeCompose(t, "services: [1, 2, 3\n  broken")); err == nil {
		t.Fatal("an unparseable compose file must be an error, not an empty service list")
	}
}

// TestDiscoverAndComposeAgreeOnTheSameImages is the SYMMETRY guard: whatever image string makes
// DiscoverDatabases produce a dump must also make DBServiceNames name a service. They now share one
// predicate; this asserts the property that sharing is FOR, so a future edit to either side that
// breaks it fails here rather than in a customer's restore.
func TestDiscoverAndComposeAgreeOnTheSameImages(t *testing.T) {
	images := []string{"postgres:16", "mariadb:11", "mysql:8.4", "redis:7", "ghcr.io/x/app:1"}
	var body strings.Builder
	body.WriteString("services:\n")
	var wantDB []string
	for i, img := range images {
		svc := string(rune('a' + i))
		body.WriteString("  " + svc + ":\n    image: " + img + "\n")
		if _, ok := dbTypeForImage(img); ok {
			wantDB = append(wantDB, svc)
		}
	}
	got, err := DBServiceNames(writeCompose(t, body.String()))
	if err != nil {
		t.Fatal(err)
	}
	if !reflect.DeepEqual(got, wantDB) {
		t.Fatalf("compose resolver named %v but the discovery predicate says %v — the two sides have drifted", got, wantDB)
	}
}
