package httpresponse

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/jackc/pgx/v5/pgconn"
)

// checkViolation builds the error the employees insert actually produces.
func checkViolation() *pgconn.PgError {
	return &pgconn.PgError{
		Severity:       "ERROR",
		Code:           "23514",
		Message:        `new row for relation "employees" violates check constraint "employees_department_check"`,
		Detail:         `Failing row contains (emp-001, ..., nonexistent).`,
		Hint:           "",
		ConstraintName: "employees_department_check",
		TableName:      "employees",
	}
}

func wrapped(err error) error { return fmt.Errorf("create employee: %w", err) }

func errorOf(t *testing.T, rec *httptest.ResponseRecorder) (code string, message string) {
	t.Helper()
	body := getBody(rec)
	if body == nil {
		t.Fatalf("response body is not JSON: %q", rec.Body.String())
	}
	e, ok := body["error"].(map[string]any)
	if !ok {
		t.Fatalf(`response has no "error" object: %q`, rec.Body.String())
	}
	c, _ := e["code"].(string)
	m, _ := e["message"].(string)
	return c, m
}

// assertNoInternals fails if any withheld detail reached the response body.
func assertNoInternals(t *testing.T, rec *httptest.ResponseRecorder) {
	t.Helper()
	raw := rec.Body.String()
	for _, leak := range []string{
		"employees_department_check", // constraint name
		"employees",                  // table name
		"SQLSTATE",                   // sqlstate label
		"23514",                      // sqlstate value
		"pgconn",                     // driver package
		"pgx",                        // driver library
		"constraint",                 // driver vocabulary
		"relation",                   // driver vocabulary
		"Failing row",                // DETAIL, which quotes the rejected values
	} {
		if strings.Contains(raw, leak) {
			t.Errorf("response body leaked %q: %s", leak, raw)
		}
	}
}

// TestSafeError_CheckViolationIsSafeAndActionable is the reported instance: a
// constraint violation on insert. The positive assertions matter as much as
// the negative ones — a handler that answered 400 with an empty body would
// satisfy the negative half and tell the caller nothing.
func TestSafeError_CheckViolationIsSafeAndActionable(t *testing.T) {
	for _, tc := range []struct {
		name string
		err  error
	}{
		{"direct", checkViolation()},
		{"wrapped", wrapped(checkViolation())},
	} {
		t.Run(tc.name, func(t *testing.T) {
			rec := httptest.NewRecorder()
			SafeError(rec, "req-1", &recordingLogger{}, http.StatusBadRequest, CodeValidation, tc.err)

			if rec.Code < 400 || rec.Code > 499 {
				t.Fatalf("status = %d, want a 4xx for a caller-caused rejection, not %d", rec.Code, rec.Code)
			}
			code, message := errorOf(t, rec)
			if code != string(CodeValidation) {
				t.Errorf("code = %q, want %q", code, CodeValidation)
			}
			if strings.TrimSpace(message) == "" {
				t.Error("message is empty: the caller is left with nothing to act on")
			}
			if !strings.Contains(message, "not allowed") {
				t.Errorf("message = %q, want it to say the submitted value is not allowed", message)
			}
			assertNoInternals(t, rec)
		})
	}
}

// TestSafeError_LogsTheWithheldDetail: the point of redaction is the wire, not
// the operator. Whoever has to repair the constraint must still be able to see
// which constraint it was.
func TestSafeError_LogsTheWithheldDetail(t *testing.T) {
	log := &recordingLogger{}
	rec := httptest.NewRecorder()
	SafeError(rec, "req-1", log, http.StatusBadRequest, CodeValidation, checkViolation())

	if log.calls == 0 {
		t.Fatal("driver detail was withheld from the client and not logged at all")
	}
	joined := strings.Join(log.args, " ")
	for _, want := range []string{"employees_department_check", "23514", "req-1"} {
		if !strings.Contains(joined, want) {
			t.Errorf("log does not contain %q, so the failure cannot be diagnosed: %s", want, joined)
		}
	}
	// The rejected row is the caller's data and must not be logged.
	if strings.Contains(joined, "Failing row") {
		t.Errorf("log quoted the rejected row values: %s", joined)
	}
}

// TestSafeError_ServerFaultIsFiveHundred: a dropped connection is not the
// caller's fault and must not be reported as one.
func TestSafeError_ServerFaultIsFiveHundred(t *testing.T) {
	pge := &pgconn.PgError{
		Code:    "08006", // connection_failure
		Message: `dial tcp 10.4.0.7:5432: connect: connection refused`,
	}
	rec := httptest.NewRecorder()
	SafeError(rec, "req-1", &recordingLogger{}, http.StatusBadRequest, CodeValidation, pge)

	if rec.Code != http.StatusInternalServerError {
		t.Errorf("status = %d, want 500 for a driver fault", rec.Code)
	}
	code, message := errorOf(t, rec)
	if code != string(CodeInternal) {
		t.Errorf("code = %q, want %q", code, CodeInternal)
	}
	if message != MsgInternal {
		t.Errorf("message = %q, want the generic %q", message, MsgInternal)
	}
	if strings.Contains(rec.Body.String(), "10.4.0.7") {
		t.Error("internal hostname reached the client")
	}
}

// TestSafeMessage_DuplicateWordingIsTrueForBothSuppliers: 23505 does not mean
// the CALLER supplied the duplicate. employees.employee_number is allocated by
// the server, and a collision there reached this mapping in production. So the
// message must state a fact true in both cases without implying the caller sent
// the value.
func TestSafeMessage_DuplicateWordingIsTrueForBothSuppliers(t *testing.T) {
	got := SafeMessage(&pgconn.PgError{
		Code:           "23505",
		ConstraintName: "employees_employee_number_key",
		TableName:      "employees",
	})
	if strings.TrimSpace(got) == "" {
		t.Fatal("23505 message is empty")
	}
	if !strings.Contains(got, "already exists") {
		t.Errorf("23505 message = %q, want it to state the record already exists", got)
	}
	// "already in use" reads as "the value you sent is taken", which is false
	// for a server-allocated number and sends the user hunting for a duplicate
	// they never entered.
	for _, forbidden := range []string{"already in use", "you already", "you entered", "you supplied"} {
		if strings.Contains(strings.ToLower(got), forbidden) {
			t.Errorf("23505 message %q contains %q, which blames the caller for a value the server may have generated", got, forbidden)
		}
	}
}

// TestSafeError_PassesThroughNonDatabaseErrors: the boundary reclassifies
// driver errors only. A sentinel a handler mapped deliberately keeps both its
// status and the wording its author wrote for it — otherwise the fix would
// have to be applied by hand at every branch.
func TestSafeError_PassesThroughNonDatabaseErrors(t *testing.T) {
	sentinel := errors.New("voucher already used")
	rec := httptest.NewRecorder()
	SafeError(rec, "req-1", &recordingLogger{}, http.StatusConflict, CodeBusinessViolation, sentinel)

	if rec.Code != http.StatusConflict {
		t.Errorf("status = %d, want the handler's own 409", rec.Code)
	}
	_, message := errorOf(t, rec)
	if message != "voucher already used" {
		t.Errorf("message = %q, want the sentinel's own wording", message)
	}
}

// TestSafeMessage: the pure classifier, used where a caller builds its own
// response.
func TestSafeMessage(t *testing.T) {
	if got := SafeMessage(checkViolation()); strings.Contains(got, "employees_department_check") {
		t.Errorf("SafeMessage leaked the constraint name: %q", got)
	}
	if got := SafeMessage(errors.New("name is required")); got != "name is required" {
		t.Errorf("SafeMessage(%q) = %q, want it passed through", "name is required", got)
	}
	if got := SafeMessage(nil); got != "" {
		t.Errorf("SafeMessage(nil) = %q, want empty", got)
	}
}

// TestSafeMessageRedactsDecodeErrors covers the second class: the one call
// site that filters a body itself and needs a message rather than a written
// response (an endpoint that tolerates an empty body).
func TestSafeMessageRedactsDecodeErrors(t *testing.T) {
	type customerInput struct {
		Name string `json:"name"`
	}
	var dst customerInput
	decodeErr := json.NewDecoder(strings.NewReader(`["a"]`)).Decode(&dst)
	if decodeErr == nil {
		t.Fatal("expected a decode error")
	}
	got := SafeMessage(decodeErr)
	for _, leak := range []string{"customerInput", "Go value", "cannot unmarshal"} {
		if strings.Contains(got, leak) {
			t.Errorf("SafeMessage leaked %q: %q", leak, got)
		}
	}
	if strings.TrimSpace(got) == "" {
		t.Error("SafeMessage returned an empty message for a malformed body")
	}
}

// TestDataViolationMessagesCoverTheNeighbours: the whole family of
// caller-caused SQLSTATEs has to be safe, not just 23514.
func TestDataViolationMessagesCoverTheNeighbours(t *testing.T) {
	for _, code := range []string{"23505", "23502", "23503", "23514", "22001", "22003", "22007", "22P02"} {
		msg, ok := dataViolationMessage(code)
		if !ok {
			t.Errorf("SQLSTATE %s is not classified", code)
			continue
		}
		if strings.TrimSpace(msg) == "" {
			t.Errorf("SQLSTATE %s maps to an empty message", code)
		}
		if strings.Contains(msg, code) {
			t.Errorf("SQLSTATE %s is quoted in its own message: %q", code, msg)
		}
	}
	// A server-side fault must NOT be dressed as a caller error.
	if _, ok := dataViolationMessage("42501"); ok {
		t.Error("insufficient_privilege is not a caller-caused data violation")
	}
}

// TestDecodeJSONDoesNotLeakGoTypes is the second reported instance: the
// decoder's text names a Go type and a package.
func TestDecodeJSONDoesNotLeakGoTypes(t *testing.T) {
	type customerInput struct {
		Name string `json:"name"`
	}
	dst := customerInput{}

	rec := httptest.NewRecorder()
	r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`["a","b"]`))
	if DecodeJSON(rec, r, "req-1", &dst) {
		t.Fatal("DecodeJSON accepted a JSON array for a struct")
	}
	if rec.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", rec.Code)
	}
	raw := rec.Body.String()
	for _, leak := range []string{"customerInput", "httptest", "Go value", "partners", "CustomerInput"} {
		if strings.Contains(raw, leak) {
			t.Errorf("response leaked %q: %s", leak, raw)
		}
	}
	_, message := errorOf(t, rec)
	if strings.TrimSpace(message) == "" {
		t.Error("message is empty, so a caller cannot tell the body was wrong")
	}
}

// TestDecodeJSONNamesTheCallersOwnField: the request's own field name IS in
// the API contract, so quoting it is not a disclosure and it is actionable.
func TestDecodeJSONNamesTheCallersOwnField(t *testing.T) {
	type input struct {
		Quantity string `json:"quantity"`
	}
	dst := input{}

	rec := httptest.NewRecorder()
	r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"quantity": 3}`))
	if DecodeJSON(rec, r, "req-1", &dst) {
		t.Fatal("DecodeJSON accepted a number for a string field")
	}
	_, message := errorOf(t, rec)
	if !strings.Contains(message, "quantity") {
		t.Errorf("message = %q, want it to name the caller's own field", message)
	}
	if strings.Contains(message, "input") || strings.Contains(message, "string") {
		t.Errorf("message = %q, want no Go type name in it", message)
	}
}

func TestDecodeJSONAcceptsAValidBody(t *testing.T) {
	type input struct {
		Name string `json:"name"`
	}
	dst := input{}
	rec := httptest.NewRecorder()
	r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"Rina"}`))
	if !DecodeJSON(rec, r, "req-1", &dst) {
		t.Fatalf("DecodeJSON rejected a valid body: %s", rec.Body.String())
	}
	if rec.Code != http.StatusOK {
		t.Errorf("status = %d, want nothing written on success", rec.Code)
	}
	if dst.Name != "Rina" {
		t.Errorf("dst.Name = %q, want Rina", dst.Name)
	}
}

func TestDecodeJSONEmptyBody(t *testing.T) {
	var dst struct{ Name string }
	rec := httptest.NewRecorder()
	r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(``))
	if DecodeJSON(rec, r, "req-1", &dst) {
		t.Fatal("DecodeJSON accepted an empty body")
	}
	if rec.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", rec.Code)
	}
	_, message := errorOf(t, rec)
	if !strings.Contains(message, "required") {
		t.Errorf("message = %q, want it to say the body is required", message)
	}
}

func TestDecodeJSONNilBody(t *testing.T) {
	var dst struct{ Name string }
	rec := httptest.NewRecorder()
	r := httptest.NewRequest(http.MethodPost, "/", nil)
	r.Body = nil
	if DecodeJSON(rec, r, "req-1", &dst) {
		t.Fatal("DecodeJSON accepted a nil body")
	}
	if rec.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", rec.Code)
	}
}

// TestStatusCodesRemainDistinguishable: the redaction must not collapse the
// error taxonomy. 401, 403, 404 and 500 are four different facts about a
// request and a caller distinguishes them by status and code.
func TestStatusCodesRemainDistinguishable(t *testing.T) {
	cases := []struct {
		name   string
		status int
		code   ErrorCode
		err    error
	}{
		{"unauthenticated", http.StatusUnauthorized, CodeAuthentication, errors.New("Unauthenticated")},
		{"forbidden", http.StatusForbidden, CodeForbidden, errors.New("Forbidden")},
		{"not found", http.StatusNotFound, CodeNotFound, errors.New("Not found")},
		{"internal", http.StatusInternalServerError, CodeInternal, errors.New("Something went wrong")},
		{"constraint violation", 0, "", checkViolation()},
	}
	seen := map[int]bool{}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rec := httptest.NewRecorder()
			if tc.status == 0 {
				// The driver-error path, entered the way a handler's fallback
				// enters it.
				SafeError(rec, "req-1", &recordingLogger{}, http.StatusBadRequest, CodeValidation, tc.err)
			} else {
				Error(rec, tc.status, "req-1", tc.code, tc.err.Error(), nil)
			}
			if rec.Code < 400 || rec.Code > 599 {
				t.Fatalf("status = %d, want a 4xx or 5xx", rec.Code)
			}
			gotCode, message := errorOf(t, rec)
			if gotCode == "" {
				t.Error("code is empty")
			}
			if strings.TrimSpace(message) == "" {
				t.Error("message is empty")
			}
			if seen[rec.Code] {
				t.Errorf("status %d is used by more than one case", rec.Code)
			}
			seen[rec.Code] = true
			assertNoInternals(t, rec)
		})
	}
}

// TestErrorEnvelopeShapeUnchanged: the wire format other clients already parse
// must not move.
func TestErrorEnvelopeShapeUnchanged(t *testing.T) {
	rec := httptest.NewRecorder()
	SafeError(rec, "req-1", &recordingLogger{}, http.StatusBadRequest, CodeValidation, checkViolation())
	var body struct {
		Error struct {
			Code    string              `json:"code"`
			Message string              `json:"message"`
			Details map[string][]string `json:"details"`
		} `json:"error"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("response is not the documented envelope: %v", err)
	}
	if body.Error.Code == "" || body.Error.Message == "" {
		t.Errorf("envelope incomplete: %+v", body.Error)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
		t.Errorf("Content-Type = %q", ct)
	}
}

type recordingLogger struct {
	calls int
	args  []string
}

func (l *recordingLogger) Error(msg string, args ...any) {
	l.calls++
	l.args = append(l.args, msg)
	for i := 0; i+1 < len(args); i += 2 {
		l.args = append(l.args, fmt.Sprint(args[i]), fmt.Sprint(args[i+1]))
	}
}
