package binding

import (
	"errors"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/AgentTeam/internal/workflow/plan"
)

func TestNormalizeScheduleRevisionNormalizesIDsAndPolicy(t *testing.T) {
	spec := validScheduleSpec()
	spec.PlanID = " pln_1 "
	spec.PlanVersionID = " plv_1 "
	spec.InputPolicy.Defaults = []byte(`{"topic":"binding default"}`)

	normalized, err := NormalizeRevision(spec, bindingInputContract())
	require.NoError(t, err)
	require.Equal(t, "pln_1", normalized.Spec.PlanID)
	require.Equal(t, "plv_1", normalized.Spec.PlanVersionID)
	require.JSONEq(t, `{
  "inputPolicy":{"defaults":{"topic":"binding default"},"constants":{"secret":"fixed"}},
  "source":{"type":"schedule","schedule":{"mode":"relative","relativeSeconds":60,"inputs":{"channel":"chn_1"}}}
}`, string(normalized.ConfigJSON))
	require.Regexp(t, `^sha256:[0-9a-f]{64}$`, normalized.Digest)
}

func TestNormalizeRevisionRejectsInputPolicyCollision(t *testing.T) {
	spec := validScheduleSpec()
	spec.InputPolicy.Defaults = []byte(`{"secret":"default"}`)

	_, err := NormalizeRevision(spec, bindingInputContract())
	require.Error(t, err)
	var validation ValidationError
	require.True(t, errors.As(err, &validation))
	require.Equal(t, "binding.input_policy_collision", validation.Code)
}

func TestDecodeRevisionSpecRejectsUnknownAndDuplicateJSONFields(t *testing.T) {
	base := `{
  "planId":"pln_1","planVersionId":"plv_1",
  "inputPolicy":{"defaults":{},"constants":{}},
  "source":{"type":"schedule","schedule":{"mode":"relative","relativeSeconds":60,"inputs":{"channel":"chn_1"}}}
}`
	decoded, err := DecodeRevisionSpec([]byte(base))
	require.NoError(t, err)
	require.Equal(t, "pln_1", decoded.PlanID)

	_, err = DecodeRevisionSpec([]byte(`{"planId":"a","planId":"b"}`))
	require.Error(t, err)
	var duplicate ValidationError
	require.True(t, errors.As(err, &duplicate))
	require.Equal(t, "binding.duplicate_field", duplicate.Code)
	require.Equal(t, "$.planId", duplicate.Path)

	_, err = DecodeRevisionSpec([]byte(`{
  "planId":"pln_1","planVersionId":"plv_1","unknown":true,
  "inputPolicy":{"defaults":{},"constants":{}},
  "source":{"type":"schedule","schedule":{"mode":"relative","relativeSeconds":60,"inputs":{"channel":"chn_1"}}}
}`))
	require.Error(t, err)
	var unknown ValidationError
	require.True(t, errors.As(err, &unknown))
	require.Equal(t, "binding.invalid_json", unknown.Code)
}

func TestNormalizeRevisionRejectsRemovedManualSourceJSON(t *testing.T) {
	spec, err := DecodeRevisionSpec([]byte(`{
  "planId":"pln_1","planVersionId":"plv_1",
  "inputPolicy":{"defaults":{},"constants":{}},
  "source":{"type":"manual"}
}`))
	require.NoError(t, err)

	_, err = NormalizeRevision(spec, bindingInputContract())
	require.Error(t, err)
	var validation ValidationError
	require.True(t, errors.As(err, &validation))
	require.Equal(t, "binding.source_type", validation.Code)
	require.Equal(t, "$.source.type", validation.Path)

	_, err = DecodeRevisionSpec([]byte(`{
  "planId":"pln_1","planVersionId":"plv_1",
  "inputPolicy":{"defaults":{},"constants":{}},
  "source":{"type":"manual","manual":{"allowedRoles":["owner"],"form":{"fields":[]}}}
}`))
	require.Error(t, err)
	require.True(t, errors.As(err, &validation))
	require.Equal(t, "binding.invalid_json", validation.Code)
}

func TestDecodeRevisionPartsAndPersistedConfigDigest(t *testing.T) {
	spec, err := DecodeRevisionParts(
		"pln_1",
		"plv_1",
		[]byte(`{"type":"schedule","schedule":{"mode":"relative","relativeSeconds":60,"inputs":{"channel":"chn_1"}}}`),
		nil,
	)
	require.NoError(t, err)
	require.JSONEq(t, `{}`, string(spec.InputPolicy.Defaults))
	require.JSONEq(t, `{}`, string(spec.InputPolicy.Constants))
	normalized, err := NormalizeRevision(spec, bindingInputContract())
	require.NoError(t, err)
	require.True(t, VerifyDigest(normalized.Spec, normalized.Digest))
	require.False(t, VerifyDigest(normalized.Spec, "sha256:"+strings.Repeat("0", 64)))

	loaded, err := DecodeConfig("pln_1", "plv_1", normalized.ConfigJSON)
	require.NoError(t, err)
	reloaded, err := NormalizeRevision(loaded, bindingInputContract())
	require.NoError(t, err)
	require.Equal(t, normalized.Digest, reloaded.Digest)
	require.True(t, VerifyDigest(reloaded.Spec, normalized.Digest))

	_, err = DecodeRevisionParts("pln_1", "plv_1", []byte(`{"type":"schedule","unknown":true}`), nil)
	require.Error(t, err)
}

func TestNormalizeRevisionRejectsScheduleWithoutScheduleConfig(t *testing.T) {
	spec := validScheduleSpec()
	spec.Source.Schedule = nil
	_, err := NormalizeRevision(spec, bindingInputContract())
	require.Error(t, err)
	var validation ValidationError
	require.True(t, errors.As(err, &validation))
	require.Equal(t, "binding.source_union", validation.Code)
}

func TestRevisionDigestIsDeterministicAndPinsExactVersion(t *testing.T) {
	left := validScheduleSpec()
	left.InputPolicy.Defaults = []byte(`{"topic":"hello","count":1e0}`)
	left.Source.Schedule.Inputs = []byte(`{"channel":"chn_1","topic":"scheduled"}`)
	right := validScheduleSpec()
	right.InputPolicy.Defaults = []byte(`{"count":1.00,"topic":"hello"}`)
	right.Source.Schedule.Inputs = []byte(`{"topic":"scheduled","channel":"chn_1"}`)

	normalizedLeft, err := NormalizeRevision(left, bindingInputContract())
	require.NoError(t, err)
	normalizedRight, err := NormalizeRevision(right, bindingInputContract())
	require.NoError(t, err)
	require.Equal(t, normalizedLeft.Digest, normalizedRight.Digest)
	require.JSONEq(t, string(normalizedLeft.ConfigJSON), string(normalizedRight.ConfigJSON))

	right.PlanVersionID = "plv_2"
	changed, err := NormalizeRevision(right, bindingInputContract())
	require.NoError(t, err)
	require.NotEqual(t, normalizedLeft.Digest, changed.Digest)
}

func validScheduleSpec() RevisionSpec {
	return RevisionSpec{
		PlanID: "pln_1", PlanVersionID: "plv_1",
		InputPolicy: InputPolicy{Defaults: []byte(`{}`), Constants: []byte(`{"secret":"fixed"}`)},
		Source: Source{Type: SourceTypeSchedule, Schedule: &ScheduleSource{
			Mode: ScheduleModeRelative, RelativeSeconds: int64Pointer(60), Inputs: []byte(`{"channel":"chn_1"}`),
		}},
	}
}

func bindingInputContract() plan.ArtifactContract {
	defaultTopic := plan.ArtifactLiteral(`"plan default"`)
	return plan.ArtifactContract{
		{Name: "channel", Schema: plan.ArtifactSchema{Type: plan.TypeChannel, Required: true, Values: []string{}}},
		{Name: "count", Schema: plan.ArtifactSchema{Type: plan.TypeNumber, Values: []string{}}},
		{Name: "secret", Schema: plan.ArtifactSchema{Type: plan.TypeString, Values: []string{}}},
		{Name: "topic", Schema: plan.ArtifactSchema{Type: plan.TypeString, Default: &defaultTopic, Values: []string{}}},
	}
}

func int64Pointer(value int64) *int64 { return &value }

func mustTime(value string) *time.Time {
	parsed, err := time.Parse(time.RFC3339Nano, value)
	if err != nil {
		panic(err)
	}
	return &parsed
}
