package hr

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5/pgconn"

	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/pkg/httpresponse"
)

// checkViolationErr is what the database returns for a department the
// employees_department_check constraint does not allow. The HR form posts a
// value the user picked, so this is reachable with an ordinary request.
func checkViolationErr() *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, Rina, nonexistent, ...).`,
		ConstraintName: "employees_department_check",
		TableName:      "employees",
	}
}

// failingStore makes CreateEmployee fail the way the repository does, and
// leaves every other call to the embedded fake.
type failingStore struct {
	*fakeStore
	err error
}

func (s failingStore) CreateEmployee(context.Context, db.CreateEmployeeParams) (db.CreateEmployeeRow, error) {
	return db.CreateEmployeeRow{}, s.err
}

func postCreateEmployee(t *testing.T, store Store, body string) *httptest.ResponseRecorder {
	t.Helper()
	return postCreateEmployeeTo(t, store, &fakeSeq{}, body)
}

// postCreateEmployeeTo drives the real handler with a chosen allocator, so a
// test can model a specific allocator shape rather than a counting stub.
func postCreateEmployeeTo(t *testing.T, store Store, sq Seq, body string) *httptest.ResponseRecorder {
	t.Helper()
	r := chi.NewRouter()
	r.Post("/employees", NewHandler(newSvcWithSeq(store, sq, time.FixedZone("WIB", 7*3600))).CreateEmployee)
	res := httptest.NewRecorder()
	r.ServeHTTP(res, httptest.NewRequest(http.MethodPost, "/employees", strings.NewReader(body)))
	return res
}

// httpresponseMessageFor returns the client-facing message the shared boundary
// produces for err, reached the way an unclassified handler error reaches it.
func httpresponseMessageFor(t *testing.T, err error) string {
	t.Helper()
	res := httptest.NewRecorder()
	httpresponse.SafeError(res, "req-1", slog.New(slog.NewTextHandler(io.Discard, nil)),
		http.StatusBadRequest, httpresponse.CodeValidation, err)
	_, message := errorEnvelope(t, res)
	return message
}

func errorEnvelope(t *testing.T, res *httptest.ResponseRecorder) (code, message string) {
	t.Helper()
	var body struct {
		Error struct {
			Code    string `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	if err := decodeEnvelope(res, &body); err != nil {
		t.Fatalf("response is not the error envelope: %v (%s)", err, res.Body.String())
	}
	return body.Error.Code, body.Error.Message
}

func decodeEnvelope(res *httptest.ResponseRecorder, v any) error {
	return json.Unmarshal(res.Body.Bytes(), v)
}

func assertNoSchemaLeak(t *testing.T, res *httptest.ResponseRecorder) {
	t.Helper()
	raw := res.Body.String()
	for _, leak := range []string{
		"employees_department_check", // constraint name
		"SQLSTATE", "23514",          // sqlstate
		"relation", "constraint", // driver vocabulary
		"Failing row",   // DETAIL: quotes the rejected values
		"pgconn", "pgx", // driver packages
	} {
		if strings.Contains(raw, leak) {
			t.Errorf("response leaked %q: %s", leak, raw)
		}
	}
	// The table name on its own: the constraint name contains it, so this also
	// catches a leak of either.
	if strings.Contains(raw, "employees") && strings.Contains(raw, "relation") {
		t.Errorf("response leaked the table name: %s", raw)
	}
}

// TestCreateEmployee_ConstraintViolationIsSafe: the reported instance. Before
// the boundary existed this answered 400 with the driver's own sentence, so a
// person filling in the HR form was shown
//
//	ERROR: new row for relation "employees" violates check constraint
//	"employees_department_check" (SQLSTATE 23514)
func TestCreateEmployee_ConstraintViolationIsSafe(t *testing.T) {
	res := postCreateEmployee(t, failingStore{newFakeStore(), checkViolationErr()},
		`{"name":"Rina","nik":"3201","department":"nonexistent"}`)

	if res.Code < 400 || res.Code > 499 {
		t.Fatalf("status = %d, want a 4xx for a value the server refused", res.Code)
	}
	code, message := errorEnvelope(t, res)
	if code != "VALIDATION_ERROR" {
		t.Errorf("code = %q, want VALIDATION_ERROR", code)
	}
	// The positive half. A handler that answered 400 with an empty body would
	// pass the leak check above and still tell the user nothing, so the message
	// has to say what happened and it has to be non-empty.
	if strings.TrimSpace(message) == "" {
		t.Fatal("message is empty: the user is told nothing")
	}
	if !strings.Contains(message, "not allowed") {
		t.Errorf("message = %q, want it to say the value is not allowed", message)
	}
	assertNoSchemaLeak(t, res)
}

// TestCreateEmployee_ValidationFailureStaysActionable: the fix must not
// over-redact. An error the service wrote for the user is passed through
// unchanged, so "name is required" still reaches the form.
func TestCreateEmployee_ValidationFailureStaysActionable(t *testing.T) {
	res := postCreateEmployee(t, newFakeStore(), `{"nik":"3201"}`)

	if res.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", res.Code)
	}
	_, message := errorEnvelope(t, res)
	if strings.TrimSpace(message) == "" {
		t.Fatal("message is empty")
	}
	if !strings.Contains(message, "name is required") {
		t.Errorf("message = %q, want the service's own actionable wording", message)
	}
}

// TestCreateEmployee_MalformedBodyDoesNotNameGoTypes: the second reported
// instance, on the same endpoint.
func TestCreateEmployee_MalformedBodyDoesNotNameGoTypes(t *testing.T) {
	res := postCreateEmployee(t, newFakeStore(), `["Rina","3201"]`)

	if res.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", res.Code)
	}
	raw := res.Body.String()
	for _, leak := range []string{"EmployeeInput", "hr.", "Go value", "cannot unmarshal"} {
		if strings.Contains(raw, leak) {
			t.Errorf("response leaked %q: %s", leak, raw)
		}
	}
	_, message := errorEnvelope(t, res)
	if strings.TrimSpace(message) == "" {
		t.Error("message is empty, so the caller cannot tell the body was wrong")
	}
}

// TestRespondErr_StatusesStayDistinguishable: the redaction must not collapse
// the taxonomy. Each of these is a different fact about the request.
func TestRespondErr_StatusesStayDistinguishable(t *testing.T) {
	cases := []struct {
		name   string
		err    error
		status int
	}{
		{"not found", ErrNotFound, http.StatusNotFound},
		{"employee required", ErrEmployeeRequired, http.StatusNotFound},
		{"duplicate", ErrDuplicate, http.StatusConflict},
		{"already clocked in", ErrAlreadyClockedIn, http.StatusConflict},
		{"invalid dates", ErrInvalidDates, http.StatusBadRequest},
		{"constraint violation", checkViolationErr(), http.StatusBadRequest},
	}
	seen := map[int]string{}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			res := httptest.NewRecorder()
			r := httptest.NewRequest(http.MethodPost, "/employees", nil)
			respondErr(res, r, tc.err)
			if res.Code != tc.status {
				t.Errorf("status = %d, want %d", res.Code, tc.status)
			}
			if prev, dup := seen[res.Code]; dup {
				t.Logf("note: %s and %s share status %d, distinguished by code", prev, tc.name, res.Code)
			}
			seen[res.Code] = tc.name
			_, message := errorEnvelope(t, res)
			if strings.TrimSpace(message) == "" {
				t.Error("message is empty")
			}
			assertNoSchemaLeak(t, res)
		})
	}
}

// TestRespondErr_UniqueViolationIsSafeToo: 23505 is mapped to a sentinel in
// most repositories, so a package that does not map it would otherwise leak
// the same way.
func TestRespondErr_UniqueViolationIsSafeToo(t *testing.T) {
	pge := &pgconn.PgError{
		Code:           "23505",
		Message:        `duplicate key value violates unique constraint "employees_nik_key"`,
		ConstraintName: "employees_nik_key",
		TableName:      "employees",
	}
	res := httptest.NewRecorder()
	respondErr(res, httptest.NewRequest(http.MethodPost, "/employees", nil), pge)

	if res.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", res.Code)
	}
	raw := res.Body.String()
	if strings.Contains(raw, "employees_nik_key") || strings.Contains(raw, "23505") {
		t.Errorf("response leaked driver detail: %s", raw)
	}
	// Reached the way an unclassified handler error reaches the shared
	// boundary: raw, with no repository mapping in front of it. hr's own
	// repository maps both employee uniques before this point (see
	// mapEmployeeInsertErr), so what is under test here is the fallback.
	_, message := errorEnvelope(t, res)
	// The wording must be true whether the caller or the server supplied the
	// value. "already exists" is; "already in use" is not, because it reads as
	// "the value you sent is taken" - false for an allocated employee number.
	if !strings.Contains(message, "already exists") {
		t.Errorf("message = %q, want it to state the record already exists", message)
	}
	for _, forbidden := range []string{"already in use", "you already", "you entered"} {
		if strings.Contains(strings.ToLower(message), forbidden) {
			t.Errorf("message = %q, must not assert the caller supplied the duplicate", message)
		}
	}
}
