package settings

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

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

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

// failStore refuses the upsert, standing in for a database that is read-only,
// out of disk, or simply unreachable at the moment an operator saves.
type failStore struct{ err error }

func (f failStore) Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) {
	return pgconn.CommandTag{}, f.err
}
func (f failStore) Query(context.Context, string, ...interface{}) (pgx.Rows, error) {
	return nil, f.err
}
func (f failStore) QueryRow(context.Context, string, ...interface{}) pgx.Row { return errRow{f.err} }

type errRow struct{ err error }

func (e errRow) Scan(...interface{}) error { return e.err }

// The bug: Update() assigned the new values onto h.settings BEFORE calling
// save(), so a failed persist left the in-memory copy holding settings that
// exist nowhere else. The request correctly answered 500, and then:
//
//   - GET /settings served the unsaved values, so the operator's next screen
//     showed a save that had not happened, and
//   - GalonSnapshot — which main.go wires as galon.PriceList, the authoritative
//     unit price for galon transactions, deliberately never client input —
//     returned those same unsaved prices, so real money amounts were computed
//     from a configuration that was never stored. A restart would price the
//     same transaction differently.
func TestUpdateDoesNotCacheSettingsWhenPersistFails(t *testing.T) {
	before := GalonConfig{HargaIsi: 6000, HargaGalon: 50000, Deposit: 35000, BiayaAntar: 5000}
	h := &Handler{
		settings: SystemSettings{Galon: before},
		q:        db.New(failStore{err: errors.New("disk full")}),
	}

	req := httptest.NewRequest(http.MethodPut, "/settings", strings.NewReader(`{
		"galon": {"hargaIsi": 9999, "hargaGalon": 1, "deposit": 2, "biayaAntar": 3}
	}`))
	rec := httptest.NewRecorder()
	h.Update(rec, req)

	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500 — the caller must still be told the save failed", rec.Code)
	}

	if got := h.GalonSnapshot(); got != before {
		t.Errorf("GalonSnapshot = %+v, want %+v — a failed persist must not change the prices "+
			"galon transactions are billed from", got, before)
	}

	rec2 := httptest.NewRecorder()
	h.Get(rec2, httptest.NewRequest(http.MethodGet, "/settings", nil))
	// httpresponse.OK wraps the document: {"data": ..., "meta": ...}.
	var envelope struct {
		Data SystemSettings `json:"data"`
	}
	if err := json.Unmarshal(rec2.Body.Bytes(), &envelope); err != nil {
		t.Fatal(err)
	}
	if envelope.Data.Galon != before {
		t.Errorf("GET /settings galon = %+v, want %+v — the read path served values that were never saved",
			envelope.Data.Galon, before)
	}
}

// The inverse must keep working: a successful save commits to memory, and the
// store sees the merged document. Without this the guard above would be
// satisfied by simply never updating.
func TestUpdateCachesAndPersistsWhenSaveSucceeds(t *testing.T) {
	store := &recordingStore{}
	h := &Handler{
		settings: SystemSettings{Galon: GalonConfig{HargaIsi: 6000, HargaGalon: 50000, Deposit: 35000, BiayaAntar: 5000}},
		q:        db.New(store),
	}

	// A COMPLETE price list: the fields are plain ints, so a partial payload
	// cannot be told apart from one that means zero, and is now rejected.
	// See TestUpdateRejectsPartialGalonPayload.
	req := httptest.NewRequest(http.MethodPut, "/settings", strings.NewReader(
		`{"galon": {"hargaIsi": 7000, "hargaGalon": 50000, "deposit": 35000, "biayaAntar": 5000}}`))
	rec := httptest.NewRecorder()
	h.Update(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
	}
	if got := h.GalonSnapshot().HargaIsi; got != 7000 {
		t.Errorf("HargaIsi = %d, want 7000", got)
	}
	if store.saved == "" {
		t.Fatal("nothing was written to the store")
	}
	// The store holds the whole SystemSettings document, not just the galon half.
	var persisted SystemSettings
	if err := json.Unmarshal([]byte(store.saved), &persisted); err != nil {
		t.Fatal(err)
	}
	if persisted.Galon.HargaIsi != 7000 {
		t.Errorf("persisted HargaIsi = %d, want 7000 — what is served must be what was written",
			persisted.Galon.HargaIsi)
	}
}

type recordingStore struct{ saved string }

func (r *recordingStore) Exec(_ context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
	if b, ok := args[1].([]byte); ok {
		r.saved = string(b)
	} else if s, ok := args[1].(string); ok {
		r.saved = s
	}
	return pgconn.CommandTag{}, nil
}
func (r *recordingStore) Query(context.Context, string, ...interface{}) (pgx.Rows, error) {
	return nil, errors.New("unused")
}
func (r *recordingStore) QueryRow(context.Context, string, ...interface{}) pgx.Row {
	return errRow{errors.New("unused")}
}

// A partial galon payload used to be accepted and silently zeroed the prices it
// did not mention. GalonSnapshot is wired in main.go as galon.PriceList — the
// authoritative unit price for galon transactions — so "hargaIsi updated to
// 7000" could quietly turn the deposit and the delivery fee, and the galon
// price itself, into zero.
//
// The fields are plain ints, so omitted and zero are indistinguishable on the
// wire. Rather than contort the type, the price list is validated as a whole:
// a partial payload is now an explicit 400 naming the field, instead of a
// silent write of a price list nobody intended.
func TestUpdateRejectsPartialGalonPayload(t *testing.T) {
	before := GalonConfig{HargaIsi: 6000, HargaGalon: 50000, Deposit: 35000, BiayaAntar: 5000}
	h := &Handler{
		settings: SystemSettings{Galon: before},
		q:        db.New(&recordingStore{}),
	}

	rec := httptest.NewRecorder()
	h.Update(rec, httptest.NewRequest(http.MethodPut, "/settings",
		strings.NewReader(`{"galon": {"hargaIsi": 7000}}`)))

	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400 — a payload omitting hargaGalon would zero it", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "hargaGalon") {
		t.Errorf("body = %s, want it to name the field that is missing", rec.Body.String())
	}
	if got := h.GalonSnapshot(); got != before {
		t.Errorf("GalonSnapshot = %+v, want %+v — a rejected update must not change the prices", got, before)
	}
}

// 0 is a real configuration for deposit (no deposit required) and delivery fee
// (free delivery is a normal promotion). Only the two item prices are pinned
// above zero, because a free refill or a free galon is not a price list.
func TestUpdateAllowsZeroDepositAndDeliveryButNotFreeItems(t *testing.T) {
	base := GalonConfig{HargaIsi: 6000, HargaGalon: 50000, Deposit: 35000, BiayaAntar: 5000}

	t.Run("zero deposit and delivery fee are accepted", func(t *testing.T) {
		h := &Handler{settings: SystemSettings{Galon: base}, q: db.New(&recordingStore{})}
		rec := httptest.NewRecorder()
		h.Update(rec, httptest.NewRequest(http.MethodPut, "/settings",
			strings.NewReader(`{"galon": {"hargaIsi": 6000, "hargaGalon": 50000, "deposit": 0, "biayaAntar": 0}}`)))
		if rec.Code != http.StatusOK {
			t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
		}
		if g := h.GalonSnapshot(); g.Deposit != 0 || g.BiayaAntar != 0 {
			t.Errorf("GalonSnapshot = %+v, want deposit and biayaAntar cleared to 0", g)
		}
	})

	for _, tc := range []struct{ field, payload string }{
		{"hargaIsi", `{"galon": {"hargaIsi": 0, "hargaGalon": 50000, "deposit": 0, "biayaAntar": 0}}`},
		{"hargaGalon", `{"galon": {"hargaIsi": 6000, "hargaGalon": 0, "deposit": 0, "biayaAntar": 0}}`},
	} {
		t.Run("rejects "+tc.field+" = 0", func(t *testing.T) {
			h := &Handler{settings: SystemSettings{Galon: base}, q: db.New(&recordingStore{})}
			rec := httptest.NewRecorder()
			h.Update(rec, httptest.NewRequest(http.MethodPut, "/settings", strings.NewReader(tc.payload)))
			if rec.Code != http.StatusBadRequest {
				t.Fatalf("status = %d, want 400 — a free item is not a valid price list", rec.Code)
			}
		})
	}
}
