// Package packs_test holds the BLACK-BOX golden oracle for the SF-skin pack
// migration. It lives in the EXTERNAL test package (packs_test, not packs) so
// the Go compiler structurally forbids reaching any unexported engine
// identifier. That compiler enforcement is the guarantee that this oracle
// depends ONLY on exported API and serializes ONLY boundary-observable outputs.
//
// This is INSURANCE captured at HEAD, BEFORE any engine change, and is the sole
// end-to-end correctness gate for the migration. The migration WILL move/delete
// engine internals (sourceBackedObjectSpecComponent, parse types, etc.); none of
// those are touched here, so this oracle survives the break and is a valid
// post-break diff target.
//
// Four boundary-observable surfaces are frozen per pack:
//
//  1. Ordered DeclaredComponents() serialized to canonical (indented, stable-order) JSON.
//     Each DeclaredComponent carries its per-component PayloadHash, so surface 1
//     subsumes the per-component half of surface 2.
//  2. PayloadHash() per pack (the per-component hashes ride inside surface 1).
//  3. The persisted RuntimeStartupShallow column bytes, via the exact exported
//     accessor that produces the value written to the installed-pack DB column:
//     BuildPersistedRuntimeDeclaration(pack).StartupShallowDeclaredRowsJSON().
//  4. NewInstallPlan per-phase step grouping (kind:businessKey lists per phase,
//     in canonical phase order). Surface 4 INCLUDES pack-level sweep components
//     (InstallPlan() folds in sweepers) while surfaces 1/2 EXCLUDE them — that
//     include/exclude split is itself a property the oracle locks down.
//
// Modes:
//   - GOLDEN_RECORD=1 -> (re)write fixtures under testdata/golden_oracle/.
//   - default         -> verify live output byte-for-byte against fixtures.
package packs_test

import (
	"context"
	"encoding/json"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"testing"

	featureauth "github.com/nexuscrm/auth"
	"github.com/nexuscrm/automation"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/pingcap-inc/nexuscrm-controlplane/packs"
)

const (
	goldenRecordEnvVar         = "GOLDEN_RECORD"
	officialAppPacksRootEnvVar = "NEXUSCRM_OFFICIAL_APP_PACKS_ROOT"
	goldenFixtureSubdir        = "testdata/golden_oracle"
)

// oraclePack names one corpus member and the official_repo source to load it
// from. All three load via fully-exported API (proven at HEAD).
type oraclePack struct {
	name      string // stable fixture key
	source    string // official_repo:<name>
	connector bool   // documentation only
}

// corpus — the two on-disk packs that drive the oracle. salesforce-crm-standard
// is the real migration insurance (53 components / 23 kinds, ~29KB shallow JSON);
// hubspot-crm-connector contributes the 6 disjoint connector kinds. The former
// non-product compatibility pack is intentionally absent; v66 is excluded
// because it fails construction at HEAD and adds zero unique kinds (see
// recon-corpus.md).
var oracleCorpus = []oraclePack{
	{name: "salesforce-crm-standard", source: "official_repo:salesforce-crm-standard"},
	{name: "hubspot-crm-connector", source: "official_repo:hubspot-crm-connector", connector: true},
}

// installPlanPhaseGroup is the canonical-JSON shape for one phase of surface 4.
type installPlanPhaseGroup struct {
	Phase string   `json:"phase"`
	Steps []string `json:"steps"` // "kind:businessKey", in plan order
}

// packSurfaces bundles the four boundary-observable surfaces for one pack.
type packSurfaces struct {
	declaredComponents []byte // surface 1: canonical JSON of DeclaredComponents()
	packPayloadHash    string // surface 2: PayloadHash() (per-pack)
	runtimeShallow     string // surface 3: persisted startup-shallow column bytes
	installPlan        []byte // surface 4: canonical JSON of per-phase grouping
}

func TestGoldenOracle(t *testing.T) {
	root := officialAppPacksRoot(t)
	if root == "" {
		t.Skipf("%s not set and no sibling nexuscrm-app-packs checkout found; skipping golden oracle", officialAppPacksRootEnvVar)
	}

	// --- Fully-exported host wiring (mirrors the distribution composition root). ---
	// The SF pack declares a non-platform-browser SessionIssuerClass and four
	// renderer host requirements plus a product-dialect; these are registered by
	// a distribution build, not the platform default. All registration entry
	// points used here are EXPORTED, so the black-box guarantee holds.
	restoreIssuer := featureauth.SetProductOAuthSessionIssuerClassOwners([]featureauth.ProductOAuthSessionIssuerClassOwner{{
		Class:          "salesforce_compat",
		OwningAppPacks: []string{"salesforce-crm-standard"},
	}})
	t.Cleanup(restoreIssuer)

	packs.RegisterDefaultHostPrimitives(
		packs.HostPrimitive{Kind: "renderer", Name: "app-pack:crm/action-renderer/lead-convert"},
		packs.HostPrimitive{Kind: "renderer", Name: "app-pack:crm/action-renderer/create-record-from-source"},
		packs.HostPrimitive{Kind: "renderer", Name: "app-pack:crm/action-renderer/email-compose"},
		packs.HostPrimitive{Kind: "renderer", Name: "app-pack:crm/record-panel/activity-timeline"},
		packs.HostPrimitive{Kind: "renderer", Name: "app-pack:crm/record-panel/record-activity-feed"},
		packs.HostPrimitive{Kind: "renderer", Name: "app-pack:crm/banner/converted-lead"},
		packs.HostPrimitive{Kind: packs.HostPrimitiveKindProductDialect, Name: "salesforce-v63"},
	)

	recording := os.Getenv(goldenRecordEnvVar) != ""
	fixtureDir := goldenFixtureDir(t)
	if recording {
		if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
			t.Fatalf("mkdir fixture dir: %v", err)
		}
	}

	for _, pc := range oracleCorpus {
		pc := pc
		t.Run(pc.name, func(t *testing.T) {
			surfaces := buildPackSurfaces(t, root, pc)

			compareOrRecord(t, recording, fixtureDir, pc.name+".declared_components.json", surfaces.declaredComponents)
			compareOrRecord(t, recording, fixtureDir, pc.name+".payload_hash.txt", []byte(surfaces.packPayloadHash+"\n"))
			compareOrRecord(t, recording, fixtureDir, pc.name+".runtime_shallow.json", []byte(surfaces.runtimeShallow))
			compareOrRecord(t, recording, fixtureDir, pc.name+".install_plan.json", surfaces.installPlan)
		})
	}
}

// buildPackSurfaces loads one pack through the EXPORTED installable-source path
// and captures all four boundary-observable surfaces. Only exported funcs/methods
// and json-tagged exported structs are touched.
func buildPackSurfaces(t *testing.T, root string, pc oraclePack) packSurfaces {
	t.Helper()

	opts := packs.DefaultPackSourceOptions()
	opts.OfficialRepoRoot = root
	opts.FlowActionCallHandlerImplementations = append(opts.FlowActionCallHandlerImplementations, crmFlowActionCallHandlers()...)

	report := packs.ValidateInstallablePackSourceWithOptions(pc.source, opts)
	if report.HasErrors() {
		t.Fatalf("%s: ValidateInstallablePackSourceWithOptions: %s", pc.name, report.Error())
	}
	pack := report.Pack
	if pack == nil {
		t.Fatalf("%s: nil pack with no errors", pc.name)
	}

	// --- Surface 1: ordered DeclaredComponents() -> canonical JSON. ---
	// Slice order is load-bearing (it is the apply/declare order) and IS the thing
	// to lock; do NOT re-sort. DeclaredComponent is a json-tagged exported struct
	// whose PayloadHash field is the per-component hash (covers surface 2 per-comp).
	declared, err := pack.DeclaredComponents()
	if err != nil {
		t.Fatalf("%s: DeclaredComponents: %v", pc.name, err)
	}
	declaredJSON, err := json.MarshalIndent(declared, "", "  ")
	if err != nil {
		t.Fatalf("%s: marshal declared components: %v", pc.name, err)
	}
	declaredJSON = append(declaredJSON, '\n')

	// --- Surface 2: per-pack PayloadHash(). ---
	packHash, err := pack.PayloadHash()
	if err != nil {
		t.Fatalf("%s: PayloadHash: %v", pc.name, err)
	}

	// --- Surface 3: persisted RuntimeStartupShallow column bytes. ---
	// BuildPersistedRuntimeDeclaration returns a value of an UNEXPORTED type via an
	// EXPORTED function; Go lets an external package hold it and call its EXPORTED
	// method. StartupShallowDeclaredRowsJSON() is exactly the JSON string persisted
	// into the installed-pack DB column (post-normalization) — the true boundary form.
	persisted, err := packs.BuildPersistedRuntimeDeclaration(pack)
	if err != nil {
		t.Fatalf("%s: BuildPersistedRuntimeDeclaration: %v", pc.name, err)
	}
	runtimeShallow := persisted.StartupShallowDeclaredRowsJSON()

	// --- Surface 4: NewInstallPlan per-phase step grouping. ---
	// InstallPlan() folds in pack-level sweep components, so this grouping is the
	// sweeper-INCLUSIVE apply ordering (surfaces 1/2 are sweeper-exclusive).
	plan, err := pack.InstallPlan()
	if err != nil {
		t.Fatalf("%s: InstallPlan: %v", pc.name, err)
	}
	groups := make([]installPlanPhaseGroup, 0, len(plan.Phases()))
	for _, phase := range plan.Phases() {
		comps := plan.ComponentsForPhase(phase)
		steps := make([]string, 0, len(comps))
		for _, c := range comps {
			steps = append(steps, string(c.Kind())+":"+c.BusinessKey())
		}
		// Steps within a phase carry no engine-guaranteed total order beyond the
		// plan's own iteration; sort for a stable, convergence-corruption-sensitive
		// snapshot (any add/drop/rename of a step in a phase still trips the diff).
		sort.Strings(steps)
		groups = append(groups, installPlanPhaseGroup{Phase: string(phase), Steps: steps})
	}
	planJSON, err := json.MarshalIndent(groups, "", "  ")
	if err != nil {
		t.Fatalf("%s: marshal install plan: %v", pc.name, err)
	}
	planJSON = append(planJSON, '\n')

	return packSurfaces{
		declaredComponents: declaredJSON,
		packPayloadHash:    packHash,
		runtimeShallow:     runtimeShallow,
		installPlan:        planJSON,
	}
}

// crmFlowActionCallHandlers supplies the CRM flow action-call handler builders
// the SF pack requires. These are normally injected by the distribution
// composition root; every type/function used here is EXPORTED, so reconstructing
// them in the external test package keeps the oracle black-box.
func crmFlowActionCallHandlers() []automation.FlowActionCallHandlerImplementation {
	return []automation.FlowActionCallHandlerImplementation{
		automation.LegacySendEmailAliasFlowActionCallHandlerImplementation("crm:automation/email-simple"),
		automation.DisabledWorkflowAlertFlowActionCallHandlerImplementation("crm:automation/email-alert-disabled"),
		{
			HandlerRef: "crm:standard-action/send-record-email",
			Build: func(descriptor automation.FlowActionCallRegistrationDescriptor) (automation.FlowActionCallRegistration, error) {
				return automation.FlowActionCallRegistration{
					ActionType:      descriptor.ActionType,
					HandlerRef:      descriptor.HandlerRef,
					AtomicPolicy:    automation.FlowActionCallAtomicPolicyReject,
					PreviewPolicy:   automation.FlowActionCallPreviewPolicyRejectEmailIO,
					RequiresContext: automation.FlowActionCallContextStandardActionProtected,
					SendsEmail:      true,
					Handler: func(context.Context, *automation.FlowExecutor, *models.FlowInterview, *models.FlowMetadata, *models.FlowActionCall, *models.UserSession) (string, error) {
						return "", nil
					},
				}, nil
			},
		},
		{
			HandlerRef: "crm:standard-action/stamp-converted-anchors",
			Build: func(descriptor automation.FlowActionCallRegistrationDescriptor) (automation.FlowActionCallRegistration, error) {
				return automation.FlowActionCallRegistration{
					ActionType:      descriptor.ActionType,
					HandlerRef:      descriptor.HandlerRef,
					AtomicPolicy:    automation.FlowActionCallAtomicPolicySafe,
					RequiresContext: automation.FlowActionCallContextStandardActionProtected,
					Handler: func(context.Context, *automation.FlowExecutor, *models.FlowInterview, *models.FlowMetadata, *models.FlowActionCall, *models.UserSession) (string, error) {
						return "", nil
					},
				}, nil
			},
		},
		{
			HandlerRef: "crm:standard-action/change-owner",
			Build: func(descriptor automation.FlowActionCallRegistrationDescriptor) (automation.FlowActionCallRegistration, error) {
				return automation.FlowActionCallRegistration{
					ActionType:      descriptor.ActionType,
					HandlerRef:      descriptor.HandlerRef,
					AtomicPolicy:    automation.FlowActionCallAtomicPolicyReject,
					RequiresContext: automation.FlowActionCallContextStandardActionProtected,
					Handler: func(context.Context, *automation.FlowExecutor, *models.FlowInterview, *models.FlowMetadata, *models.FlowActionCall, *models.UserSession) (string, error) {
						return "", nil
					},
				}, nil
			},
		},
		{
			HandlerRef: "platform:connector/recover-remote-identity",
			Build: func(descriptor automation.FlowActionCallRegistrationDescriptor) (automation.FlowActionCallRegistration, error) {
				return automation.FlowActionCallRegistration{
					ActionType:      descriptor.ActionType,
					HandlerRef:      descriptor.HandlerRef,
					AtomicPolicy:    automation.FlowActionCallAtomicPolicyReject,
					PreviewPolicy:   automation.FlowActionCallPreviewPolicyReject,
					RequiresContext: automation.FlowActionCallContextNone,
					Handler: func(context.Context, *automation.FlowExecutor, *models.FlowInterview, *models.FlowMetadata, *models.FlowActionCall, *models.UserSession) (string, error) {
						return "", nil
					},
				}, nil
			},
		},
	}
}

// compareOrRecord writes the fixture when recording, else asserts byte equality.
func compareOrRecord(t *testing.T, recording bool, dir, name string, got []byte) {
	t.Helper()
	path := filepath.Join(dir, name)
	if recording {
		if err := os.WriteFile(path, got, 0o644); err != nil {
			t.Fatalf("write fixture %s: %v", name, err)
		}
		t.Logf("recorded %s (%d bytes)", name, len(got))
		return
	}
	want, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read fixture %s (run with %s=1 to record): %v", name, goldenRecordEnvVar, err)
	}
	if string(got) != string(want) {
		t.Errorf("golden mismatch for %s: live output differs from recorded fixture (%d live bytes vs %d fixture bytes). "+
			"This is the migration break-detector firing. Re-run with %s=1 only after confirming the change is intended.",
			name, len(got), len(want), goldenRecordEnvVar)
	}
}

// goldenFixtureDir returns the absolute testdata/golden_oracle dir next to this file.
func goldenFixtureDir(t *testing.T) string {
	t.Helper()
	_, thisFile, _, ok := runtime.Caller(0)
	if !ok {
		t.Fatal("runtime.Caller failed")
	}
	return filepath.Join(filepath.Dir(thisFile), filepath.FromSlash(goldenFixtureSubdir))
}

// officialAppPacksRoot resolves the nexuscrm-app-packs checkout: env var first,
// then the conventional sibling of the backend repo. Returns "" if neither exists
// so the caller can skip cleanly (oracle still runs without the sibling repo).
func officialAppPacksRoot(t *testing.T) string {
	t.Helper()
	if root := os.Getenv(officialAppPacksRootEnvVar); root != "" {
		if resolved, err := filepath.EvalSymlinks(root); err == nil {
			return resolved
		}
		return root
	}
	_, thisFile, _, ok := runtime.Caller(0)
	if !ok {
		return ""
	}
	// thisFile = .../controlplane/packs/golden_oracle_test.go
	// repo root = up 2 dirs; sibling = <parent>/nexuscrm-app-packs.
	pkgDir := filepath.Dir(thisFile)
	backendRoot := filepath.Clean(filepath.Join(pkgDir, "..", ".."))
	sibling := filepath.Join(filepath.Dir(backendRoot), "nexuscrm-app-packs")
	if info, err := os.Stat(sibling); err == nil && info.IsDir() {
		if resolved, err := filepath.EvalSymlinks(sibling); err == nil {
			return resolved
		}
		return sibling
	}
	return ""
}
