//go:build crm

package appsource

import (
	"bytes"
	"encoding/json"
	"errors"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"strings"
	"testing"

	"github.com/nexuscrm/shared/pkg/contract"
	"golang.org/x/mod/modfile"
)

const officialAppPacksRootEnvVar = "NEXUSCRM_OFFICIAL_APP_PACKS_ROOT"

func TestDistributionAppPacksSourceTopologyIsComplete(t *testing.T) {
	missing := appPacksDialectSourceTopologyMissing(t)
	if len(missing) > 0 {
		t.Fatalf("app-packs dialect source topology is incomplete (see docs/design/crm-dialect-source-extraction.md): %v", missing)
	}
}

func TestOfficialAppPacksSourceRevisionIsOnMainLineage(t *testing.T) {
	root := strings.TrimSpace(os.Getenv(officialAppPacksRootEnvVar))
	if root == "" {
		t.Skipf("%s is required to verify OfficialAppPacksSourceRevision lineage", officialAppPacksRootEnvVar)
	}

	assertAppPacksRevisionOnMainLineage(t, root, "OfficialAppPacksSourceRevision", OfficialAppPacksSourceRevision)
	assertAppPacksRevisionOnMainLineage(t, root, "SalesforceCRMStandardPackSourceRevision", SalesforceCRMStandardPackSourceRevision)
}

func TestSalesforceCRMStandardPackSourceDeclaresIdentitySyncDeactivationTicketPolicy(t *testing.T) {
	root := strings.TrimSpace(os.Getenv(officialAppPacksRootEnvVar))
	if root == "" {
		t.Skipf("%s is required to verify the pinned CRM pack source", officialAppPacksRootEnvVar)
	}

	const manifestPath = "salesforce-crm-standard/pack.json"
	raw := []byte(gitOutput(t, root, "show", SalesforceCRMStandardPackSourceRevision+":"+manifestPath))
	var manifest struct {
		Components []struct {
			Kind        string `json:"kind"`
			BusinessKey string `json:"businessKey"`
			Path        string `json:"path"`
		} `json:"components"`
	}
	if err := json.Unmarshal(raw, &manifest); err != nil {
		t.Fatalf("decode %s at %s: %v", manifestPath, SalesforceCRMStandardPackSourceRevision, err)
	}

	const wantPath = "policies/identity_sync_deactivation_ticket_record_mutation_denials.json"
	for _, component := range manifest.Components {
		if component.Kind == "object_record_mutation_policy" &&
			component.BusinessKey == "IdentitySyncDeactivationTicket" &&
			component.Path == wantPath {
			gitOutput(t, root, "show", SalesforceCRMStandardPackSourceRevision+":salesforce-crm-standard/"+wantPath)
			return
		}
	}
	t.Fatalf(
		"CRM pack source %s does not declare object_record_mutation_policy/IdentitySyncDeactivationTicket at %s",
		SalesforceCRMStandardPackSourceRevision,
		wantPath,
	)
}

type dialectHostRequirements struct {
	SchemaVersion     string                `json:"schema_version"`
	RequiredProtocols []protocolRequirement `json:"required_protocols"`
}

type protocolRequirement struct {
	ProtocolID   string `json:"protocol_id"`
	Major        uint32 `json:"major"`
	MinimumMinor uint32 `json:"minimum_minor"`
}

func TestOfficialAppPacksSourceRequiresProvidedRollupMaintenanceProtocol(t *testing.T) {
	root := strings.TrimSpace(os.Getenv(officialAppPacksRootEnvVar))
	if root == "" {
		t.Skipf("%s is required to verify the pinned dialect host requirements", officialAppPacksRootEnvVar)
	}

	const requirementPath = "dialects/dialect-sf-crm/host-requirements.json"
	raw := []byte(gitOutput(t, root, "show", OfficialAppPacksSourceRevision+":"+requirementPath))
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.DisallowUnknownFields()
	var got dialectHostRequirements
	if err := decoder.Decode(&got); err != nil {
		t.Fatalf("decode %s at %s: %v", requirementPath, OfficialAppPacksSourceRevision, err)
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		if err == nil {
			t.Fatalf("%s at %s contains more than one JSON value", requirementPath, OfficialAppPacksSourceRevision)
		}
		t.Fatalf("decode trailing %s content at %s: %v", requirementPath, OfficialAppPacksSourceRevision, err)
	}

	want := dialectHostRequirements{
		SchemaVersion: "nexuscrm.dialect-host-requirements/v1",
		RequiredProtocols: []protocolRequirement{{
			ProtocolID:   contract.RollupMaintenanceServingAttestationProtocolID,
			Major:        uint32(contract.RollupMaintenanceServingAttestationProtocolMajor),
			MinimumMinor: uint32(contract.RollupMaintenanceServingAttestationProtocolMinor),
		}},
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf(
			"official app-packs source %s host requirements = %#v, want exact provided protocol %#v",
			OfficialAppPacksSourceRevision,
			got,
			want,
		)
	}
}

func assertAppPacksRevisionOnMainLineage(t *testing.T, root, name, revision string) {
	t.Helper()
	gitOutput(t, root, "cat-file", "-e", revision+"^{commit}")
	mainRef := firstResolvableGitRef(t, root, "origin/main", "refs/remotes/origin/main", "main")
	if mainRef == "" {
		t.Fatalf("cannot verify %s main-lineage: fetch app-packs main in %s", name, root)
	}
	gitOutput(t, root, "merge-base", "--is-ancestor", revision, mainRef)
}

func TestFirstPartyProductPackFactsSeparateCurrentAndRetired(t *testing.T) {
	current := FirstPartyProductPackNames()
	if len(current) != 1 || current[0] != SalesforceCRMStandardPackName {
		t.Fatalf("FirstPartyProductPackNames = %#v, want current CRM product only", current)
	}
	retired := RetiredFirstPartyProductPackNames()
	if len(retired) != 1 || retired[0] != RetiredIssueTrackerStandardPackName {
		t.Fatalf("RetiredFirstPartyProductPackNames = %#v, want sole negative tombstone", retired)
	}
	if strings.EqualFold(current[0], retired[0]) {
		t.Fatalf("current and retired first-party product facts overlap: %q", current[0])
	}
}

func appPacksDialectSourceTopologyMissing(t *testing.T) []string {
	t.Helper()
	var missing []string
	if dirExists(t, repoPath("dialect-sf-crm")) {
		missing = append(missing, "remove platform-staged dialect-sf-crm directory")
	}
	if dirExists(t, repoPath("dialect-sf-crm-soql")) {
		missing = append(missing, "remove platform-staged dialect-sf-crm-soql directory")
	}
	if !fileExists(t, repoPath("nexuscrm-app-packs", "dialects", "dialect-sf-crm", "go.mod")) {
		missing = append(missing, "nexuscrm-app-packs/dialects/dialect-sf-crm/go.mod")
	}
	if !fileExists(t, repoPath("nexuscrm-app-packs", "dialects", "dialect-sf-crm-soql", "go.mod")) {
		missing = append(missing, "nexuscrm-app-packs/dialects/dialect-sf-crm-soql/go.mod")
	}
	if !fileExists(t, repoPath("nexuscrm-app-packs", "sfcrm", "scim", "go.mod")) {
		missing = append(missing, "nexuscrm-app-packs/sfcrm/scim/go.mod")
	}
	if goWorkUsesAny(t, repoPath("go.work"), "./dialect-sf-crm", "./dialect-sf-crm-soql") {
		missing = append(missing, "go.work must not use platform-staged dialect modules")
	}
	if goModHasAnyReplace(t, repoPath("backend", "go.mod"),
		"github.com/nexuscrm/dialect-sf-crm",
		"github.com/nexuscrm/dialect-sf-crm-soql",
	) {
		missing = append(missing, "backend/go.mod must not own app-packs dialect replaces after A3")
	}
	if !goModHasReplace(t, repoPath("backend", "distributions", "go.mod"), "github.com/nexuscrm/dialect-sf-crm", "../../nexuscrm-app-packs/dialects/dialect-sf-crm") ||
		!goModHasReplace(t, repoPath("backend", "distributions", "go.mod"), "github.com/nexuscrm/dialect-sf-crm-soql", "../../nexuscrm-app-packs/dialects/dialect-sf-crm-soql") {
		missing = append(missing, "backend/distributions/go.mod app-packs dialect replaces")
	}
	if !goModHasRequire(t, repoPath("backend", "distributions", "go.mod"), "github.com/pingcap-inc/nexuscrm-app-packs/sfcrm/scim", "v0.0.0") ||
		!goModHasReplace(t, repoPath("backend", "distributions", "go.mod"), "github.com/pingcap-inc/nexuscrm-app-packs/sfcrm/scim", "../../nexuscrm-app-packs/sfcrm/scim") {
		missing = append(missing, "backend/distributions/go.mod sfcrm SCIM require and replace")
	}

	if !goModHasReplace(t, repoPath("nexuscrm-app-packs", "dialects", "dialect-sf-crm", "go.mod"), "github.com/nexuscrm/shared", "../../../shared") {
		missing = append(missing, "dialect-sf-crm/go.mod platform shared replace")
	}
	if !goModHasReplace(t, repoPath("nexuscrm-app-packs", "dialects", "dialect-sf-crm", "go.mod"), "github.com/nexuscrm/dialect-sf-crm-soql", "../dialect-sf-crm-soql") {
		missing = append(missing, "dialect-sf-crm/go.mod local SOQL dialect replace")
	}
	if !goModHasReplace(t, repoPath("nexuscrm-app-packs", "dialects", "dialect-sf-crm-soql", "go.mod"), "github.com/nexuscrm/shared", "../../../shared") {
		missing = append(missing, "dialect-sf-crm-soql/go.mod platform shared replace")
	}
	return missing
}

func fileExists(t *testing.T, path string) bool {
	t.Helper()
	info, err := os.Stat(path)
	if err == nil {
		return !info.IsDir()
	}
	if os.IsNotExist(err) {
		return false
	}
	t.Fatalf("stat %s: %v", path, err)
	return false
}

func fileContainsAll(t *testing.T, path string, values ...string) bool {
	t.Helper()
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}
	for _, value := range values {
		if !strings.Contains(string(data), value) {
			return false
		}
	}
	return true
}

func dirExists(t *testing.T, path string) bool {
	t.Helper()
	info, err := os.Stat(path)
	if err == nil {
		return info.IsDir()
	}
	if os.IsNotExist(err) {
		return false
	}
	t.Fatalf("stat %s: %v", path, err)
	return false
}

func goWorkUsesAny(t *testing.T, path string, want ...string) bool {
	t.Helper()
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}
	work, err := modfile.ParseWork(path, data, nil)
	if err != nil {
		t.Fatalf("parse %s: %v", path, err)
	}
	wantSet := map[string]bool{}
	for _, path := range want {
		wantSet[filepath.ToSlash(path)] = true
	}
	for _, use := range work.Use {
		if wantSet[filepath.ToSlash(use.Path)] {
			return true
		}
	}
	return false
}

func goModHasReplace(t *testing.T, path, oldPath, newPath string) bool {
	t.Helper()
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}
	mod, err := modfile.Parse(path, data, nil)
	if err != nil {
		t.Fatalf("parse %s: %v", path, err)
	}
	for _, replace := range mod.Replace {
		if replace.Old.Path == oldPath && filepath.ToSlash(replace.New.Path) == filepath.ToSlash(newPath) {
			return true
		}
	}
	return false
}

func goModHasRequire(t *testing.T, path, modulePath, version string) bool {
	t.Helper()
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}
	mod, err := modfile.Parse(path, data, nil)
	if err != nil {
		t.Fatalf("parse %s: %v", path, err)
	}
	for _, requirement := range mod.Require {
		if requirement.Mod.Path == modulePath && requirement.Mod.Version == version {
			return true
		}
	}
	return false
}

func goModHasAnyReplace(t *testing.T, path string, oldPaths ...string) bool {
	t.Helper()
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}
	mod, err := modfile.Parse(path, data, nil)
	if err != nil {
		t.Fatalf("parse %s: %v", path, err)
	}
	oldSet := map[string]struct{}{}
	for _, oldPath := range oldPaths {
		oldSet[oldPath] = struct{}{}
	}
	for _, replace := range mod.Replace {
		if _, ok := oldSet[replace.Old.Path]; ok {
			return true
		}
	}
	return false
}

func firstResolvableGitRef(t *testing.T, root string, refs ...string) string {
	t.Helper()
	for _, ref := range refs {
		if _, err := gitOutputErr(root, "rev-parse", "--verify", ref+"^{commit}"); err == nil {
			return ref
		}
	}
	return ""
}

func gitOutput(t *testing.T, root string, args ...string) string {
	t.Helper()
	out, err := gitOutputErr(root, args...)
	if err != nil {
		t.Fatalf("git -C %s %s: %v\n%s", root, strings.Join(args, " "), err, out)
	}
	return out
}

func gitOutputErr(root string, args ...string) (string, error) {
	cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
	out, err := cmd.CombinedOutput()
	return strings.TrimSpace(string(out)), err
}

func repoPath(parts ...string) string {
	all := append([]string{"..", "..", ".."}, parts...)
	return filepath.Join(all...)
}
