package mynalog

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

	"github.com/google/uuid"
)

// msk is the taxpayer's time zone in these tests. It matters: the offset a moment is rendered with
// decides which tax month it lands in.
var msk = time.FixedZone("MSK", 3*60*60)

// capture records what the fake cabinet received, so a test can assert on the request the client
// built. raw is kept alongside the decoded body because some assertions are about JSON *shape* —
// notably that an amount is a number and not a string.
type capture struct {
	method string
	path   string
	query  string
	bearer string
	raw    string
	body   map[string]any
}

// fakeCabinet serves one canned response and records the request, returning a client already
// pointed at it.
func fakeCabinet(t *testing.T, status int, response string) (*Client, *capture) {
	t.Helper()
	got := &capture{}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got.method = r.Method
		got.path = r.URL.Path
		got.query = r.URL.RawQuery
		got.bearer = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
		if data, _ := io.ReadAll(r.Body); len(data) > 0 {
			got.raw = string(data)
			_ = json.Unmarshal(data, &got.body)
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		_, _ = io.WriteString(w, response)
	}))
	t.Cleanup(srv.Close)
	return NewClient(srv.URL, "device-1", srv.Client()), got
}

// liveSession is a session with a token that has not aged out.
func liveSession() Session {
	return Session{Token: "access-1", RefreshToken: "refresh-1", INN: "770000000000",
		ExpiresAt: time.Now().Add(time.Hour)}
}

func TestAuthByPasswordBuildsTheRequest(t *testing.T) {
	c, got := fakeCabinet(t, http.StatusOK, `{
		"token":"access-1","refreshToken":"refresh-1",
		"tokenExpireIn":"2026-07-28T15:04:05+03:00",
		"profile":{"inn":"770000000000"}}`)

	now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk)
	sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now)
	if err != nil {
		t.Fatalf("auth: %v", err)
	}
	if got.path != "/api/v1/auth/lkfl" {
		t.Fatalf("path = %q", got.path)
	}
	if got.body["username"] != "770000000000" || got.body["password"] != "hunter2" {
		t.Fatalf("credentials not sent: %v", got.body)
	}
	device, _ := got.body["deviceInfo"].(map[string]any)
	if device["sourceDeviceId"] != "device-1" || device["sourceType"] != "WEB" {
		t.Fatalf("deviceInfo = %v", device)
	}
	if sess.Token != "access-1" || sess.RefreshToken != "refresh-1" || sess.INN != "770000000000" {
		t.Fatalf("session = %+v", sess)
	}
	// The stated expiry, less the renewal slack.
	want := time.Date(2026, 7, 28, 15, 3, 5, 0, msk)
	if !sess.ExpiresAt.Equal(want) {
		t.Fatalf("expires = %v, want %v", sess.ExpiresAt, want)
	}
}

func TestAuthByPasswordFallsBackToTheLoginForTheINN(t *testing.T) {
	c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-1","refreshToken":"refresh-1"}`)

	now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk)
	sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now)
	if err != nil {
		t.Fatalf("auth: %v", err)
	}
	if sess.INN != "770000000000" {
		t.Fatalf("inn = %q", sess.INN)
	}
	if !sess.Valid(now) {
		t.Fatal("session should be valid right after authenticating")
	}
}

// A rejected password is a permanent failure, not an aged-out token: reporting it as ErrAuthExpired
// would send the caller into a pointless refresh loop.
func TestAuthByPasswordRejectionIsPermanent(t *testing.T) {
	c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"Неверный логин или пароль"}`)

	_, err := c.AuthByPassword(context.Background(), "770000000000", "wrong", time.Now())
	if !errors.Is(err, ErrPermanent) {
		t.Fatalf("err = %v, want ErrPermanent", err)
	}
	if errors.Is(err, ErrAuthExpired) {
		t.Fatal("a bad password must not look like an expired token")
	}
}

func TestAuthByRefreshKeepsTheOldTokenWhenNotRotated(t *testing.T) {
	c, got := fakeCabinet(t, http.StatusOK, `{"token":"access-2"}`)

	sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now())
	if err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if got.path != "/api/v1/auth/token" || got.body["refreshToken"] != "refresh-1" {
		t.Fatalf("request = %s %s %v", got.method, got.path, got.body)
	}
	if sess.RefreshToken != "refresh-1" {
		t.Fatalf("refresh token = %q, want the supplied one carried forward", sess.RefreshToken)
	}
}

// The service sometimes issues a new refresh token; losing it would strand the next renewal.
func TestAuthByRefreshSurfacesARotatedToken(t *testing.T) {
	c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-2","refreshToken":"refresh-2"}`)

	sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now())
	if err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if sess.RefreshToken != "refresh-2" {
		t.Fatalf("refresh token = %q, want the rotated one", sess.RefreshToken)
	}
}

func TestAuthByRefreshRejectionIsPermanent(t *testing.T) {
	c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"token is invalid"}`)

	_, err := c.AuthByRefresh(context.Background(), "stale", "770000000000", time.Now())
	if !errors.Is(err, ErrPermanent) {
		t.Fatalf("err = %v, want ErrPermanent — only a fresh password login can recover", err)
	}
}

func TestAddIncomeBuildsTheRequest(t *testing.T) {
	c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)

	receipt, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
		Name:          `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`,
		Amount:        "149.50",
		OperationTime: time.Date(2026, 7, 28, 23, 30, 0, 0, msk),
		RequestTime:   time.Date(2026, 7, 29, 0, 5, 0, 0, msk),
	})
	if err != nil {
		t.Fatalf("add income: %v", err)
	}
	if receipt != "20abcdef01" {
		t.Fatalf("receipt = %q", receipt)
	}
	if got.path != "/api/v1/income" || got.bearer != "access-1" {
		t.Fatalf("request = %s %s bearer=%q", got.method, got.path, got.bearer)
	}
	if got.body["operationTime"] != "2026-07-28T23:30:00+03:00" {
		t.Fatalf("operationTime = %v — the offset decides the tax month", got.body["operationTime"])
	}
	if got.body["requestTime"] != "2026-07-29T00:05:00+03:00" {
		t.Fatalf("requestTime = %v", got.body["requestTime"])
	}
	if got.body["totalAmount"] != "149.50" {
		t.Fatalf("totalAmount = %v, want the exact decimal string", got.body["totalAmount"])
	}
	if got.body["paymentType"] != paymentTypeCash {
		t.Fatalf("paymentType = %v", got.body["paymentType"])
	}
	if got.body["ignoreMaxTotalIncomeRestriction"] != false {
		t.Fatal("the annual ceiling must stay enforced")
	}
	services, _ := got.body["services"].([]any)
	if len(services) != 1 {
		t.Fatalf("services = %v", got.body["services"])
	}
	line, _ := services[0].(map[string]any)
	if line["quantity"] != float64(1) {
		t.Fatalf("quantity = %v", line["quantity"])
	}
	payer, _ := got.body["client"].(map[string]any)
	if payer["incomeType"] != incomeTypeIndividual || payer["inn"] != nil {
		t.Fatalf("client = %v — a private buyer carries no INN", payer)
	}
}

// The amount must reach the tax service as a JSON number carrying the exact decimal we hold, with
// no float32/float64 round-trip anywhere in between.
func TestAddIncomeSendsTheAmountAsANumber(t *testing.T) {
	c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)

	if _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
		Name: "x", Amount: "149.50",
		OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
	}); err != nil {
		t.Fatalf("add income: %v", err)
	}
	if !strings.Contains(got.raw, `"amount":149.50`) {
		t.Fatalf("body = %s, want a bare JSON number for amount", got.raw)
	}
}

func TestAddIncomeRejectsAnAmountThatIsNotDecimal(t *testing.T) {
	c, _ := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)

	for _, bad := range []string{"", "1,50", "1e2", "-5", "149.50 RUB"} {
		_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
			Name: "x", Amount: bad,
			OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
		})
		if !errors.Is(err, ErrPermanent) {
			t.Fatalf("amount %q: err = %v, want ErrPermanent", bad, err)
		}
	}
}

// A 200 with no receipt id leaves the income unannullable, so it counts as ambiguous and the caller
// must probe rather than assume it failed.
func TestAddIncomeWithoutAReceiptIsTransient(t *testing.T) {
	c, _ := fakeCabinet(t, http.StatusOK, `{}`)

	_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
		Name: "x", Amount: "10",
		OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
	})
	if !errors.Is(err, ErrTransient) {
		t.Fatalf("err = %v, want ErrTransient", err)
	}
}

func TestErrorClassification(t *testing.T) {
	cases := []struct {
		name    string
		status  int
		body    string
		want    error
		notWant error
	}{
		{"expired token", http.StatusUnauthorized, `{"message":"unauthorized"}`, ErrAuthExpired, ErrPermanent},
		{"throttled", http.StatusTooManyRequests, `{"message":"slow down"}`, ErrThrottled, ErrPermanent},
		{"server fault", http.StatusBadGateway, `{}`, ErrTransient, ErrPermanent},
		{"bad request", http.StatusBadRequest, `{"message":"bad operationTime"}`, ErrPermanent, ErrTransient},
		{"annual ceiling", http.StatusBadRequest, `{"message":"Превышен лимит дохода"}`, ErrIncomeLimit, ErrTransient},
	}
	// The ceiling refines a permanent rejection rather than replacing it: a caller that only knows
	// about ErrPermanent must still stop on it.
	if !errors.Is(ErrIncomeLimit, ErrPermanent) {
		t.Fatal("ErrIncomeLimit must subsume ErrPermanent")
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			c, _ := fakeCabinet(t, tc.status, tc.body)
			_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
				Name: "x", Amount: "10",
				OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
			})
			if !errors.Is(err, tc.want) {
				t.Fatalf("err = %v, want %v", err, tc.want)
			}
			if errors.Is(err, tc.notWant) {
				t.Fatalf("err = %v must not also be %v", err, tc.notWant)
			}
			var apiErr *APIError
			if !errors.As(err, &apiErr) || apiErr.StatusCode != tc.status {
				t.Fatalf("err does not carry the status: %v", err)
			}
		})
	}
}

// A connection that never answers is indistinguishable from one whose request was applied, so it
// has to be transient — the caller probes before retrying.
func TestUnreachableServiceIsTransient(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
	url := srv.URL
	srv.Close()

	c := NewClient(url, "device-1", &http.Client{Timeout: time.Second})
	_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
		Name: "x", Amount: "10",
		OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
	})
	if !errors.Is(err, ErrTransient) {
		t.Fatalf("err = %v, want ErrTransient", err)
	}
}

func TestCancelIncomeBuildsTheRequest(t *testing.T) {
	c, got := fakeCabinet(t, http.StatusOK, `{}`)

	now := time.Date(2026, 8, 1, 10, 0, 0, 0, msk)
	if err := c.CancelIncome(context.Background(), liveSession(), "20abcdef01", now); err != nil {
		t.Fatalf("cancel: %v", err)
	}
	if got.path != "/api/v1/cancel" {
		t.Fatalf("path = %q", got.path)
	}
	if got.body["receiptUuid"] != "20abcdef01" {
		t.Fatalf("receiptUuid = %v", got.body["receiptUuid"])
	}
	if got.body["comment"] != cancelReasonRefund {
		t.Fatalf("comment = %v, want the reason the service recognises", got.body["comment"])
	}
	if got.body["operationTime"] != "2026-08-01T10:00:00+03:00" {
		t.Fatalf("operationTime = %v", got.body["operationTime"])
	}
}

func TestFindIncomeMatchesTheExactName(t *testing.T) {
	c, got := fakeCabinet(t, http.StatusOK, `{"content":[
		{"name":"другая покупка","approvedReceiptUuid":"other"},
		{"name":"нужный чек","approvedReceiptUuid":"wanted"}]}`)

	from := time.Date(2026, 7, 28, 0, 0, 0, 0, msk)
	to := time.Date(2026, 7, 29, 0, 0, 0, 0, msk)
	uuid, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек", from, to)
	if err != nil {
		t.Fatalf("find: %v", err)
	}
	if !found || uuid != "wanted" {
		t.Fatalf("found = %v, uuid = %q", found, uuid)
	}
	if got.path != "/api/v1/incomes" {
		t.Fatalf("path = %q", got.path)
	}
	if !strings.Contains(got.query, "from=2026-07-28T00%3A00%3A00.000%2B03%3A00") {
		t.Fatalf("query = %q", got.query)
	}
}

// An annulled receipt must never satisfy the probe: annulling it again would fail, and treating it
// as ours would hide an income that really is missing.
func TestFindIncomeSkipsAnnulledReceipts(t *testing.T) {
	c, _ := fakeCabinet(t, http.StatusOK, `{"content":[
		{"name":"нужный чек","approvedReceiptUuid":"dead","cancellationInfo":{"comment":"Возврат средств"}}]}`)

	_, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек",
		time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk))
	if err != nil {
		t.Fatalf("find: %v", err)
	}
	if found {
		t.Fatal("an annulled receipt must not be reported as ours")
	}
}

func TestFindIncomeReportsAbsence(t *testing.T) {
	c, _ := fakeCabinet(t, http.StatusOK, `{"content":[]}`)

	_, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек",
		time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk))
	if err != nil {
		t.Fatalf("find: %v", err)
	}
	if found {
		t.Fatal("found = true on an empty list")
	}
}

func TestDeviceIDForLoginIsStable(t *testing.T) {
	first := DeviceIDForLogin("770000000000")
	if first != DeviceIDForLogin("770000000000") {
		t.Fatal("the device id must not change between runs")
	}
	if len(first) != deviceIDLength {
		t.Fatalf("device id = %q, want %d characters", first, deviceIDLength)
	}
	if first == DeviceIDForLogin("770000000001") {
		t.Fatal("different logins must yield different devices")
	}
}

func TestReceiptURL(t *testing.T) {
	c := NewClient("https://lknpd.example.test", "device-1", nil)
	got := c.ReceiptURL("770000000000", "20abcdef01")
	want := "https://lknpd.example.test/api/v1/receipt/770000000000/20abcdef01/print"
	if got != want {
		t.Fatalf("url = %q, want %q", got, want)
	}
}

func TestIncomeName(t *testing.T) {
	order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")

	got := IncomeName(100, order, "")
	want := `Внутриигровая валюта: "Фишка", 100 шт. (ID: cafe0001)`
	if got != want {
		t.Fatalf("name = %q, want %q", got, want)
	}
}

// The marker must come from the random tail of a UUIDv7, not its clock-derived head: the first
// eight hex digits are the high bits of a millisecond timestamp and are shared by every order placed
// within about a minute. Two such orders would be indistinguishable to the recovery probe, which is
// how an income ends up filed twice.
func TestIncomeNameMarkerIsNotClockDerived(t *testing.T) {
	// Two ids one millisecond apart, differing only in their random tail.
	first := uuid.MustParse("0198c1f2-0001-7000-8000-000000000001")
	second := uuid.MustParse("0198c1f2-0002-7000-8000-000000000002")

	if IncomeName(100, first, "") == IncomeName(100, second, "") {
		t.Fatal("two orders in the same time bucket share a service name")
	}
}

func TestIncomeNameFallsBackToTheProductTitle(t *testing.T) {
	order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")

	if got := IncomeName(0, order, "Подсказки"); got != "Подсказки (ID: cafe0001)" {
		t.Fatalf("name = %q", got)
	}
	if got := IncomeName(0, order, "   "); got != defaultTitle+" (ID: cafe0001)" {
		t.Fatalf("name = %q, want the default description", got)
	}
}

// The marker is what makes the post-timeout probe exact, so the description gives way first and the
// marker is never truncated.
func TestIncomeNameCapsLengthButKeepsTheMarker(t *testing.T) {
	order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")

	got := IncomeName(0, order, strings.Repeat("Ы", 400))
	if n := len([]rune(got)); n != maxNameRunes {
		t.Fatalf("length = %d runes, want %d", n, maxNameRunes)
	}
	if !strings.HasSuffix(got, "(ID: cafe0001)") {
		t.Fatalf("name = %q, the marker must survive truncation", got)
	}
}

func TestCryptoRoundTrip(t *testing.T) {
	key, err := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
	if err != nil {
		t.Fatalf("parse key: %v", err)
	}
	blob, err := Seal(key, "refresh-1")
	if err != nil {
		t.Fatalf("seal: %v", err)
	}
	if strings.Contains(string(blob), "refresh-1") {
		t.Fatal("the sealed blob still contains the plaintext")
	}
	got, err := Open(key, blob)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	if got != "refresh-1" {
		t.Fatalf("opened %q", got)
	}
}

// Losing or rotating the key must be a nuisance — sign in again — not a crash.
func TestOpenWithAWrongKeyIsUndecryptable(t *testing.T) {
	good, _ := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
	other, _ := ParseKey("MTIzNDU2NzhhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dzA=")

	blob, err := Seal(good, "refresh-1")
	if err != nil {
		t.Fatalf("seal: %v", err)
	}
	if _, err := Open(other, blob); !errors.Is(err, ErrUndecryptable) {
		t.Fatalf("err = %v, want ErrUndecryptable", err)
	}
	if _, err := Open(good, blob[:4]); !errors.Is(err, ErrUndecryptable) {
		t.Fatalf("truncated blob: err = %v, want ErrUndecryptable", err)
	}
}

func TestParseKey(t *testing.T) {
	if _, err := ParseKey("6162636465666768696a6b6c6d6e6f707172737475767778797a313233343536"); err != nil {
		t.Fatalf("hex key rejected: %v", err)
	}
	for _, bad := range []string{"", "short", "YWJj"} {
		if _, err := ParseKey(bad); err == nil {
			t.Fatalf("ParseKey(%q) accepted a key of the wrong size", bad)
		}
	}
}
