package controlplane

import (
	"context"
	"strings"
	"testing"
	"time"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/pingcap-inc/nexuscrm-controlplane/packs"
)

// ── gate 9 §7: apply tests (upsert idempotent, soft-delete on removing, no-dep skips) ─

// stubPackWithDeps is a minimal Pack that implements PackDependencyProvider.
type stubPackWithDeps struct {
	name string
	deps []packs.PackDependency
}

func (p *stubPackWithDeps) Name() string                                       { return p.name }
func (p *stubPackWithDeps) Category() packs.Category                           { return packs.AppPack }
func (p *stubPackWithDeps) Apply(_ context.Context, _ packs.ApplyDeps) error   { return nil }
func (p *stubPackWithDeps) Verify(_ context.Context, _ packs.VerifyDeps) error { return nil }
func (p *stubPackWithDeps) PackDependencies() []packs.PackDependency {
	return append([]packs.PackDependency(nil), p.deps...)
}

// stubPackNoDeps does NOT implement PackDependencyProvider — it represents a
// connector-pack or legacy pack that never declares manifest dependencies.
// upsertPackDependencyRows should be a no-op for this type (early return on !ok).
type stubPackNoDeps struct {
	name string
}

func (p *stubPackNoDeps) Name() string                                       { return p.name }
func (p *stubPackNoDeps) Category() packs.Category                           { return packs.AppPack }
func (p *stubPackNoDeps) Apply(_ context.Context, _ packs.ApplyDeps) error   { return nil }
func (p *stubPackNoDeps) Verify(_ context.Context, _ packs.VerifyDeps) error { return nil }

// TestUpsertPackDependencyRows_IdempotentOnReapply (gate 9a §7):
// When a pack with one dependency is reapplied, upsertPackDependencyRows issues an
// ON DUPLICATE KEY UPDATE UPSERT — idempotent (the same row is refreshed, not
// duplicated). It also issues a soft-delete NOT IN (...) UPDATE for any previously-
// declared deps that are no longer in the manifest (none to drop here, but the UPDATE
// runs regardless to keep the dep set converged).
func TestUpsertPackDependencyRows_IdempotentOnReapply(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	pack := &stubPackWithDeps{
		name: "my-companion-app",
		deps: []packs.PackDependency{
			{Name: "my-connector-pack", VersionConstraint: ">=1.0.0"},
		},
	}
	packInstallID := "install-uuid-001"

	// Expect: SELECT PackInstallId FROM _System_InstalledPack WHERE Name = ?
	mock.ExpectQuery(`SELECT COALESCE\(PackInstallId, ''\)`).
		WithArgs("my-companion-app").
		WillReturnRows(sqlmock.NewRows([]string{"COALESCE(PackInstallId, '')"}).AddRow(packInstallID))

	// Expect: INSERT … ON DUPLICATE KEY UPDATE (UPSERT for current dep)
	mock.ExpectExec(`INSERT INTO _System_InstalledPackDependency`).
		WillReturnResult(sqlmock.NewResult(1, 1))

	// Expect: soft-delete NOT IN (my-connector-pack) for any stale deps (0 rows here).
	mock.ExpectExec(`UPDATE _System_InstalledPackDependency\s+SET IsDeleted = 1`).
		WillReturnResult(sqlmock.NewResult(0, 0))

	if err := upsertPackDependencyRows(context.Background(), db, pack, time.Now().UTC()); err != nil {
		t.Fatalf("upsertPackDependencyRows: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("unmet expectations: %v", err)
	}
}

// TestUpsertPackDependencyRows_NoDeps_NoWrite (gate 9c §7):
// When a pack has no dependencies, upsertPackDependencyRows must not issue any DB write.
func TestUpsertPackDependencyRows_NoDeps_NoWrite(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	pack := &stubPackNoDeps{name: "no-deps-pack"}
	// No expectations: zero DB calls expected.
	if err := upsertPackDependencyRows(context.Background(), db, pack, time.Now().UTC()); err != nil {
		t.Fatalf("upsertPackDependencyRows: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("unmet expectations: %v", err)
	}
}

// ─────────────────────────────────────────────────────────────────────────────
// HIGH2 regression — upsertPackDependencyRows must reconcile (soft-delete stale
// deps) on every warm reapply, not only on uninstall.
// ─────────────────────────────────────────────────────────────────────────────

// TestUpsertPackDependencyRows_ReconcileDroppedDep (HIGH2):
// apply {A,B} → then reapply {A} → B row must be soft-deleted; A still active.
func TestUpsertPackDependencyRows_ReconcileDroppedDep(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	pack := &stubPackWithDeps{
		name: "my-companion-app",
		deps: []packs.PackDependency{
			{Name: "dep-a", VersionConstraint: ""},
		},
	}
	packInstallID := "install-uuid-reapply"
	now := time.Now().UTC()

	// SELECT PackInstallId.
	mock.ExpectQuery(`SELECT COALESCE\(PackInstallId, ''\)`).
		WithArgs("my-companion-app").
		WillReturnRows(sqlmock.NewRows([]string{"COALESCE(PackInstallId, '')"}).AddRow(packInstallID))

	// UPSERT dep-a (the one that remains).
	mock.ExpectExec(`INSERT INTO _System_InstalledPackDependency`).
		WillReturnResult(sqlmock.NewResult(1, 1))

	// Soft-delete dep-b (dropped from manifest) via NOT IN (dep-a).
	mock.ExpectExec(`UPDATE _System_InstalledPackDependency\s+SET IsDeleted = 1`).
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := upsertPackDependencyRows(context.Background(), db, pack, now); err != nil {
		t.Fatalf("upsertPackDependencyRows: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("unmet expectations (stale dep not soft-deleted?): %v", err)
	}
}

// TestUpsertPackDependencyRows_AllDepsDropped (HIGH2):
// reapply with deps {} → all existing dep rows must be soft-deleted.
func TestUpsertPackDependencyRows_AllDepsDropped(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	pack := &stubPackWithDeps{
		name: "my-companion-app",
		deps: []packs.PackDependency{}, // pack dropped all deps
	}
	packInstallID := "install-uuid-nodeps"
	now := time.Now().UTC()

	// SELECT PackInstallId — still needed even with zero deps.
	mock.ExpectQuery(`SELECT COALESCE\(PackInstallId, ''\)`).
		WithArgs("my-companion-app").
		WillReturnRows(sqlmock.NewRows([]string{"COALESCE(PackInstallId, '')"}).AddRow(packInstallID))

	// No UPSERTs (empty dep list).

	// Soft-delete ALL existing non-deleted rows.
	mock.ExpectExec(`UPDATE _System_InstalledPackDependency\s+SET IsDeleted = 1`).
		WillReturnResult(sqlmock.NewResult(0, 2))

	if err := upsertPackDependencyRows(context.Background(), db, pack, now); err != nil {
		t.Fatalf("upsertPackDependencyRows (all deps dropped): %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("unmet expectations (all-deps soft-delete not issued?): %v", err)
	}
}

// TestInstalledPackDepID_Deterministic (gate 9 — deterministic id):
// installedPackDepID returns the same id for the same (dependent, dependsOn) pair,
// and different ids for different pairs.
func TestInstalledPackDepID_Deterministic(t *testing.T) {
	id1 := installedPackDepID("companion-app", "connector-pack")
	id2 := installedPackDepID("companion-app", "connector-pack")
	if id1 != id2 {
		t.Errorf("installedPackDepID not deterministic: %q != %q", id1, id2)
	}

	idOther := installedPackDepID("different-app", "connector-pack")
	if id1 == idOther {
		t.Errorf("different apps should produce different IDs, got same: %q", id1)
	}
	if strings.TrimSpace(id1) == "" {
		t.Error("installedPackDepID returned empty string")
	}

	// Collision resistance: pack names differing only by characters that a lossy
	// alphanumeric encoding would collapse (e.g. '-' vs '_') MUST yield distinct
	// ids, or a later apply would refresh the wrong dependency row. (HIGH: the
	// previous non-alphanumeric->'_' encoding aliased "a-b" and "a_b".)
	if installedPackDepID("a-b", "dep") == installedPackDepID("a_b", "dep") {
		t.Error(`"a-b"->dep and "a_b"->dep aliased to the same id (lossy PK regression)`)
	}
	// Name-boundary ambiguity must not alias either: ("ab","c") vs ("a","bc").
	if installedPackDepID("ab", "c") == installedPackDepID("a", "bc") {
		t.Error(`("ab","c") and ("a","bc") aliased to the same id (boundary ambiguity)`)
	}
}
