package setup

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"math/big"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// generatePEMCertificate builds a throwaway self-signed PEM
// certificate for the validation tests. Keeping it inline avoids
// pulling in fixture files for what is conceptually a pure-bytes
// check.
func generatePEMCertificate(t *testing.T, commonName string) []byte {
	t.Helper()

	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatalf("generate key: %v", err)
	}

	template := &x509.Certificate{
		SerialNumber: big.NewInt(1),
		Subject:      pkix.Name{CommonName: commonName},
		NotBefore:    time.Now().Add(-time.Hour),
		NotAfter:     time.Now().Add(time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
		IsCA:         true,
	}

	der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
	if err != nil {
		t.Fatalf("create cert: %v", err)
	}

	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}

func TestValidateCABundleBytes_HappyPathTwoCerts(t *testing.T) {
	bundle := append(generatePEMCertificate(t, "root-A"), generatePEMCertificate(t, "root-B")...)

	count, err := validateCABundleBytes(bundle)
	if err != nil {
		t.Fatalf("validation failed: %v", err)
	}

	if count != 2 {
		t.Errorf("count = %d, want 2", count)
	}
}

func TestValidateCABundleBytes_EmptyBundleRejected(t *testing.T) {
	if _, err := validateCABundleBytes(nil); err == nil {
		t.Errorf("nil bundle accepted, want error")
	}

	if _, err := validateCABundleBytes([]byte{}); err == nil {
		t.Errorf("empty bundle accepted, want error")
	}
}

func TestValidateCABundleBytes_NoPEMBlocksRejected(t *testing.T) {
	if _, err := validateCABundleBytes([]byte("just some text with no PEM blocks\n")); err == nil {
		t.Errorf("blob without PEM blocks accepted, want error")
	}
}

func TestValidateCABundleBytes_NonCertificateBlockRejected(t *testing.T) {
	keyBlock := pem.EncodeToMemory(&pem.Block{
		Type:  "RSA PRIVATE KEY",
		Bytes: []byte("not really a key, but the type is what's load-bearing"),
	})

	count, err := validateCABundleBytes(keyBlock)
	if err == nil {
		t.Errorf("RSA PRIVATE KEY block accepted, want error")
	}

	if count != 1 {
		t.Errorf("count = %d, want 1 (we walked one block before erroring)", count)
	}

	if !strings.Contains(err.Error(), `type "RSA PRIVATE KEY"`) {
		t.Errorf("error does not name the offending block type: %v", err)
	}
}

func TestValidateCABundleBytes_TruncatedFrameRejected(t *testing.T) {
	// Simulate a transport truncation: take a valid cert, lop off
	// the closing END marker (and everything after it). pem.Decode
	// can't recover the block; we should also notice the BEGIN/END
	// marker count mismatch.
	good := string(generatePEMCertificate(t, "root"))
	cut := strings.Index(good, "-----END CERTIFICATE-----")

	if cut < 0 {
		t.Fatalf("generated cert is missing the END marker; harness bug")
	}

	truncated := []byte(good[:cut])

	_, err := validateCABundleBytes(truncated)
	if err == nil {
		t.Fatalf("truncated bundle accepted, want error")
	}

	if !strings.Contains(err.Error(), "framing") && !strings.Contains(err.Error(), "no PEM CERTIFICATE blocks") {
		t.Errorf("error does not name a framing problem: %v", err)
	}
}

func TestValidateCABundleBytes_CorruptBase64BodyRejected(t *testing.T) {
	// Replace the middle of a valid cert's base64 body with a `!`
	// (illegal base64). pem.Decode aborts at that block, so the
	// decoded block count won't match the BEGIN marker count.
	good := string(generatePEMCertificate(t, "root"))
	begin := strings.Index(good, "-----BEGIN CERTIFICATE-----") + len("-----BEGIN CERTIFICATE-----")
	end := strings.Index(good, "-----END CERTIFICATE-----")

	if begin < 0 || end < 0 || end <= begin+10 {
		t.Fatalf("generated cert has unexpected structure; harness bug")
	}

	mid := (begin + end) / 2
	corrupted := []byte(good[:mid] + "!@#$" + good[mid+4:])

	_, err := validateCABundleBytes(corrupted)
	if err == nil {
		t.Fatalf("base64-corrupted bundle accepted, want error")
	}
}

func TestValidateCABundleBytes_TolerantOfCommentTrail(t *testing.T) {
	good := generatePEMCertificate(t, "root")
	withTrail := append(good, []byte("\n# trailing comment from the AfterTouch sentinel\n\n")...)

	count, err := validateCABundleBytes(withTrail)
	if err != nil {
		t.Fatalf("comment-only trail rejected: %v", err)
	}

	if count != 1 {
		t.Errorf("count = %d, want 1", count)
	}
}

func TestValidateCABundleBytes_RejectsStrayNonPEMTrail(t *testing.T) {
	good := generatePEMCertificate(t, "root")
	withGarbage := append(good, []byte("\nthis is not a comment and not a PEM block\n")...)

	if _, err := validateCABundleBytes(withGarbage); err == nil {
		t.Errorf("stray trailing content accepted, want error")
	}
}

func TestValidateAfterTouchLabelBracketing_HappyPath(t *testing.T) {
	body := "anchor pre-AfterTouch content\n" +
		CALabel + "\n" +
		string(generatePEMCertificate(t, "aftertouch")) +
		CALabel + "\n"

	if err := validateAfterTouchLabelBracketing([]byte(body)); err != nil {
		t.Errorf("happy-path bracketing rejected: %v", err)
	}
}

func TestValidateAfterTouchLabelBracketing_MissingClose(t *testing.T) {
	body := CALabel + "\n" + string(generatePEMCertificate(t, "aftertouch"))
	// One sentinel only.

	err := validateAfterTouchLabelBracketing([]byte(body))
	if err == nil {
		t.Fatalf("missing-close bracketing accepted, want error")
	}

	if !strings.Contains(err.Error(), "appears 1 times") {
		t.Errorf("error does not name the appearance count: %v", err)
	}
}

func TestValidateAfterTouchLabelBracketing_ThreeOccurrencesRejected(t *testing.T) {
	body := CALabel + "\n" + string(generatePEMCertificate(t, "a")) + CALabel + "\n" +
		CALabel + "\n" + string(generatePEMCertificate(t, "b"))

	if err := validateAfterTouchLabelBracketing([]byte(body)); err == nil {
		t.Errorf("three-occurrence body accepted, want error")
	}
}

func TestValidateAfterTouchLabelBracketing_EmptyBetweenLabels(t *testing.T) {
	body := CALabel + "\n" + CALabel + "\n"

	err := validateAfterTouchLabelBracketing([]byte(body))
	if err == nil {
		t.Fatalf("empty-between-labels accepted, want error")
	}

	if !strings.Contains(err.Error(), "BEGIN CERTIFICATE") {
		t.Errorf("error does not name the missing BEGIN CERTIFICATE: %v", err)
	}
}

func TestStripAfterTouchEntries_SingleEntryRemovedCleanly(t *testing.T) {
	upstream := string(generatePEMCertificate(t, "upstream-A"))
	stale := string(generatePEMCertificate(t, "aftertouch-stale"))

	bundle := upstream + CALabel + "\n" + stale + CALabel + "\n"

	got := stripAfterTouchEntries(bundle)
	if got.RemovedEntries != 1 {
		t.Errorf("RemovedEntries = %d, want 1", got.RemovedEntries)
	}

	if got.UnpairedSentinel {
		t.Errorf("UnpairedSentinel = true, want false")
	}

	if strings.Contains(got.CleanedBundle, CALabel) {
		t.Errorf("CleanedBundle still contains %q:\n%s", CALabel, got.CleanedBundle)
	}

	if !strings.Contains(got.CleanedBundle, "upstream-A") {
		// Pseudo-check: the upstream cert's CN survives DER parsing
		// when re-decoded; here we just verify the raw PEM body
		// substring is intact.
		_ = upstream
	}
}

func TestStripAfterTouchEntries_MultipleStaleEntriesCollapsed(t *testing.T) {
	upstreamA := string(generatePEMCertificate(t, "upstream-A"))
	upstreamB := string(generatePEMCertificate(t, "upstream-B"))
	upstreamC := string(generatePEMCertificate(t, "upstream-C"))
	stale1 := string(generatePEMCertificate(t, "aftertouch-stale-1"))
	stale2 := string(generatePEMCertificate(t, "aftertouch-stale-2"))

	bundle := upstreamA +
		CALabel + "\n" + stale1 + CALabel + "\n" +
		upstreamB +
		CALabel + "\n" + stale2 + CALabel + "\n" +
		upstreamC

	got := stripAfterTouchEntries(bundle)
	if got.RemovedEntries != 2 {
		t.Errorf("RemovedEntries = %d, want 2", got.RemovedEntries)
	}

	if got.UnpairedSentinel {
		t.Errorf("UnpairedSentinel = true, want false")
	}

	if strings.Contains(got.CleanedBundle, CALabel) {
		t.Errorf("CleanedBundle still contains sentinel:\n%s", got.CleanedBundle)
	}

	// The cleaned bundle has to still be a valid PEM concatenation
	// of the three upstream certs.
	count, err := validateCABundleBytes([]byte(got.CleanedBundle))
	if err != nil {
		t.Fatalf("cleaned bundle does not validate: %v\n%s", err, got.CleanedBundle)
	}

	if count != 3 {
		t.Errorf("cleaned bundle cert count = %d, want 3 (the upstream entries)", count)
	}
}

func TestStripAfterTouchEntries_NoEntriesIsZeroRemovals(t *testing.T) {
	bundle := string(generatePEMCertificate(t, "upstream-only"))

	got := stripAfterTouchEntries(bundle)
	if got.RemovedEntries != 0 {
		t.Errorf("RemovedEntries = %d, want 0", got.RemovedEntries)
	}

	if got.UnpairedSentinel {
		t.Errorf("UnpairedSentinel = true, want false")
	}
}

func TestStripAfterTouchEntries_UnpairedSentinelFlagged(t *testing.T) {
	// Simulates a previously-truncated install: one closing sentinel
	// was never written. Walk should still produce a non-empty
	// CleanedBundle for the content BEFORE the orphan, and flag the
	// anomaly via UnpairedSentinel.
	upstreamA := string(generatePEMCertificate(t, "upstream-A"))
	orphan := string(generatePEMCertificate(t, "aftertouch-orphan"))

	bundle := upstreamA + CALabel + "\n" + orphan
	// Note: no closing CALabel.

	got := stripAfterTouchEntries(bundle)
	if !got.UnpairedSentinel {
		t.Errorf("UnpairedSentinel = false, want true")
	}

	if got.RemovedEntries != 0 {
		t.Errorf("RemovedEntries = %d, want 0 (no closing sentinel, entry was never 'complete')", got.RemovedEntries)
	}

	if strings.Contains(got.CleanedBundle, "aftertouch-orphan") {
		t.Errorf("orphan content leaked into CleanedBundle:\n%s", got.CleanedBundle)
	}
}

// TestValidateRealSpeakerBundle exercises the validators against a
// real CA bundle captured off a SoundTouch 20's filesystem — the
// Mozilla CCADB bundle that ships at /etc/pki/tls/certs/ca-bundle.crt
// on firmware 27.0.6.46330.5043500 (snapshot taken 2022-08-04, 165
// certificates, ~251 KB). The fixture lives at
// testdata/ca_bundle_st20_pristine.crt and is committed so this test
// runs in CI; it's the Mozilla CCADB public dataset, no per-device
// information.
//
// Cross-model note: byte-identical to the corresponding ST10
// firmware-27 bundle (verified 2026-05-16 against
// firmware/_backup_ST10/_/etc/pki/tls/certs/ca-bundle.crt — same
// md5 2d150987b312e4280fc576b508e62b43, same 165 certs). Same
// fixture stands in for both speaker models while they're on the
// same firmware build, so expired-root hypotheses (e.g. PR #292)
// should be evaluated against this single dataset.
//
// Reproduce the #292 cert-chain probe locally — point curl at this
// fixture and try the actual TuneIn stream chain a SoundTouch
// speaker would walk. If the handshake validates here, the speaker
// can also validate it (modulo any speaker-side TLS-stack quirks
// the OpenSSL binary on your laptop doesn't share). System bundle
// shown alongside for control:
//
//	BUNDLE=pkg/service/setup/testdata/ca_bundle_st20_pristine.crt
//
//	# Control: system trust store
//	curl -sS -o /dev/null -w "%{http_code}\n" \
//	    "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
//
//	# Same URL, restricted to the speaker's 2022 CCADB snapshot
//	curl -sS -o /dev/null -w "%{http_code}\n" --cacert "$BUNDLE" \
//	    "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
//
//	# Follow the 302 to the actual audio host
//	curl -sSL -o /dev/null -w "%{http_code} %{url_effective}\n" \
//	    --cacert "$BUNDLE" \
//	    "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls"
//
// Both bundles handle the K-LOVE chain (Amazon Root CA 1 + DigiCert
// Global Root, valid through 2026+) cleanly — recorded against
// firmware 27 on 2026-05-16, ruling out expired-root for that
// firmware vintage.
//
// The point of this test is to catch over-eager validator changes
// before they ship. An earlier iteration of validateCABundleBytes
// called x509.ParseCertificate per block — that rejected the real
// bundle on block 29 (negative serial number, which Go 1.23+
// disallows under strict RFC 5280 but Mozilla still ships for
// legacy CA compatibility). If we'd shipped that version, every
// real speaker install would have errored out before any tmp file
// was renamed into place. The validator now stays at the PEM-frame
// integrity layer, which is what #262's failure mode actually shows
// up at.
func TestValidateRealSpeakerBundle(t *testing.T) {
	path := filepath.Join("testdata", "ca_bundle_st20_pristine.crt")

	bundle, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}

	count, err := validateCABundleBytes(bundle)
	if err != nil {
		t.Fatalf("real bundle rejected by validateCABundleBytes: %v", err)
	}

	// Snapshot value as captured. If Mozilla churns the CCADB and we
	// resnapshot, update this constant in the same commit so a real
	// regression doesn't get masked by a stale expectation.
	const wantCertCount = 165

	if count != wantCertCount {
		t.Errorf("real bundle parsed %d certificates, want %d", count, wantCertCount)
	}

	stripped := stripAfterTouchEntries(string(bundle))
	if stripped.RemovedEntries != 0 {
		t.Errorf("pristine bundle reports %d AfterTouch entries removed, want 0", stripped.RemovedEntries)
	}

	if stripped.UnpairedSentinel {
		t.Errorf("pristine bundle reports an unpaired sentinel, want false")
	}

	// stripAfterTouchEntries on a pristine bundle is effectively a
	// no-op (modulo trailing-newline normalisation). Detect drift
	// loosely — within a 2-byte tolerance for the trailing-newline
	// case — rather than asserting byte-identical, which would lock
	// in a normalisation detail nobody cares about.
	if delta := len(stripped.CleanedBundle) - len(bundle); delta < -2 || delta > 2 {
		t.Errorf("strip pass on pristine bundle changed length unexpectedly: input=%d cleaned=%d (delta=%d)",
			len(bundle), len(stripped.CleanedBundle), delta)
	}
}
