//go:build integration

package integration

import (
	"bytes"
	"context"
	"database/sql"
	"errors"
	"io"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"sync"
	"syscall"
	"testing"
	"time"

	"github.com/eggsampler/acme/v3"
	"github.com/jmhodges/clock"

	"github.com/letsencrypt/boulder/core"
	"github.com/letsencrypt/boulder/test"
	"github.com/letsencrypt/boulder/test/vars"
)

// crlUpdaterMu controls access to `runUpdater`, because two crl-updaters running
// at once will result in errors trying to lease shards that are already leased.
var crlUpdaterMu sync.Mutex

// runUpdater executes the crl-updater binary with the -runOnce flag, and
// returns when it completes.
func runUpdater(t *testing.T, configFile string) {
	t.Helper()
	crlUpdaterMu.Lock()
	defer crlUpdaterMu.Unlock()

	// Reset the s3-test-srv so that it only knows about serials contained in
	// this new batch of CRLs.
	resp, err := http.Post("http://localhost:4501/reset", "", bytes.NewReader([]byte{}))
	test.AssertNotError(t, err, "opening database connection")
	test.AssertEquals(t, resp.StatusCode, http.StatusOK)

	// Reset the "leasedUntil" column so this can be done alongside other
	// updater runs without worrying about unclean state.
	fc := clock.NewFake()
	db, err := sql.Open("mysql", vars.DBConnSAFullPerms)
	test.AssertNotError(t, err, "opening database connection")
	_, err = db.Exec(`UPDATE crlShards SET leasedUntil = ?`, fc.Now().Add(-time.Minute))
	test.AssertNotError(t, err, "resetting leasedUntil column")

	binPath, err := filepath.Abs("bin/boulder")
	test.AssertNotError(t, err, "computing boulder binary path")

	c := exec.Command(binPath, "crl-updater", "-config", configFile, "-debug-addr", ":8022", "-runOnce")
	out, err := c.CombinedOutput()
	for _, line := range strings.Split(string(out), "\n") {
		// Print the updater's stdout for debugging, but only if the test fails.
		t.Log(line)
	}
	test.AssertNotError(t, err, "crl-updater failed")
}

// TestCRLUpdaterStartup ensures that the crl-updater can start in daemon mode.
// We do this here instead of in startservers so that we can shut it down after
// we've confirmed it is running. It's important that it not be running while
// other CRL integration tests are running, because otherwise they fight over
// database leases, leading to flaky test failures.
func TestCRLUpdaterStartup(t *testing.T) {
	t.Parallel()

	crlUpdaterMu.Lock()
	defer crlUpdaterMu.Unlock()

	ctx, cancel := context.WithCancel(context.Background())

	binPath, err := filepath.Abs("bin/boulder")
	test.AssertNotError(t, err, "computing boulder binary path")

	configDir, ok := os.LookupEnv("BOULDER_CONFIG_DIR")
	test.Assert(t, ok, "failed to look up test config directory")
	configFile := path.Join(configDir, "crl-updater.json")

	c := exec.CommandContext(ctx, binPath, "crl-updater", "-config", configFile, "-debug-addr", ":8021")

	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		out, err := c.CombinedOutput()
		// Log the output and error, but only if the main goroutine couldn't connect
		// and declared the test failed.
		for _, line := range strings.Split(string(out), "\n") {
			t.Log(line)
		}
		t.Log(err)
		wg.Done()
	}()

	for attempt := range 10 {
		time.Sleep(core.RetryBackoff(attempt, 10*time.Millisecond, 1*time.Second, 2))

		conn, err := net.DialTimeout("tcp", "localhost:8021", 100*time.Millisecond)
		if errors.Is(err, syscall.ECONNREFUSED) {
			t.Logf("Connection attempt %d failed: %s", attempt, err)
			continue
		}
		if err != nil {
			t.Logf("Connection attempt %d failed unrecoverably: %s", attempt, err)
			t.Fail()
			break
		}
		t.Logf("Connection attempt %d succeeded", attempt)
		defer conn.Close()
		break
	}

	cancel()
	wg.Wait()
}

// TestCRLPipeline runs an end-to-end test of the crl issuance process, ensuring
// that the correct number of properly-formed and validly-signed CRLs are sent
// to our fake S3 service.
func TestCRLPipeline(t *testing.T) {
	// Basic setup.
	configDir, ok := os.LookupEnv("BOULDER_CONFIG_DIR")
	test.Assert(t, ok, "failed to look up test config directory")
	configFile := path.Join(configDir, "crl-updater.json")

	// Create a database connection so we can pretend to jump forward in time.
	db, err := sql.Open("mysql", vars.DBConnSAFullPerms)
	test.AssertNotError(t, err, "creating database connection")

	// Issue a test certificate and save its serial number.
	client, err := makeClient()
	test.AssertNotError(t, err, "creating acme client")
	res, err := authAndIssue(client, nil, []acme.Identifier{{Type: "dns", Value: random_domain()}}, true, "")
	test.AssertNotError(t, err, "failed to create test certificate")
	cert := res.certs[0]
	serial := core.SerialToString(cert.SerialNumber)

	// Confirm that the cert does not yet show up as revoked in the CRLs.
	runUpdater(t, configFile)
	resp, err := http.Get("http://localhost:4501/query?serial=" + serial)
	test.AssertNotError(t, err, "s3-test-srv GET /query failed")
	test.AssertEquals(t, resp.StatusCode, 404)
	resp.Body.Close()

	// Revoke the certificate.
	err = client.RevokeCertificate(client.Account, cert, client.PrivateKey, 5)
	test.AssertNotError(t, err, "failed to revoke test certificate")

	// Confirm that the cert now *does* show up in the CRLs, with the right reason.
	runUpdater(t, configFile)
	resp, err = http.Get("http://localhost:4501/query?serial=" + serial)
	test.AssertNotError(t, err, "s3-test-srv GET /query failed")
	test.AssertEquals(t, resp.StatusCode, 200)
	reason, err := io.ReadAll(resp.Body)
	test.AssertNotError(t, err, "reading revocation reason")
	test.AssertEquals(t, string(reason), "5")
	resp.Body.Close()

	// Manipulate the database so it appears that the certificate is going to
	// expire very soon. The cert should still appear on the CRL.
	_, err = db.Exec("UPDATE revokedCertificates SET notAfterHour = ? WHERE serial = ?", time.Now().Add(time.Hour).Truncate(time.Hour).Format(time.DateTime), serial)
	test.AssertNotError(t, err, "updating expiry to near future")
	runUpdater(t, configFile)
	resp, err = http.Get("http://localhost:4501/query?serial=" + serial)
	test.AssertNotError(t, err, "s3-test-srv GET /query failed")
	test.AssertEquals(t, resp.StatusCode, 200)
	reason, err = io.ReadAll(resp.Body)
	test.AssertNotError(t, err, "reading revocation reason")
	test.AssertEquals(t, string(reason), "5")
	resp.Body.Close()

	// Again update the database so that the certificate has expired in the
	// very recent past. The cert should still appear on the CRL.
	_, err = db.Exec("UPDATE revokedCertificates SET notAfterHour = ? WHERE serial = ?", time.Now().Add(-time.Hour).Truncate(time.Hour).Format(time.DateTime), serial)
	test.AssertNotError(t, err, "updating expiry to recent past")
	runUpdater(t, configFile)
	resp, err = http.Get("http://localhost:4501/query?serial=" + serial)
	test.AssertNotError(t, err, "s3-test-srv GET /query failed")
	test.AssertEquals(t, resp.StatusCode, 200)
	reason, err = io.ReadAll(resp.Body)
	test.AssertNotError(t, err, "reading revocation reason")
	test.AssertEquals(t, string(reason), "5")
	resp.Body.Close()

	// Finally update the database so that the certificate expired several CRL
	// update cycles ago. The cert should now vanish from the CRL.
	_, err = db.Exec("UPDATE revokedCertificates SET notAfterHour = ? WHERE serial = ?", time.Now().Add(-48*time.Hour).Truncate(time.Hour).Format(time.DateTime), serial)
	test.AssertNotError(t, err, "updating expiry to far past")
	runUpdater(t, configFile)
	resp, err = http.Get("http://localhost:4501/query?serial=" + serial)
	test.AssertNotError(t, err, "s3-test-srv GET /query failed")
	test.AssertEquals(t, resp.StatusCode, 404)
	resp.Body.Close()
}
