package xerr

import (
	"context"
	"errors"
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"strconv"
	"testing"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// TestToGRPCErrorCarriesReason 锁定服务底座要求的 gRPC code + ErrorInfo.reason 组合。
func TestToGRPCErrorCarriesReason(t *testing.T) {
	err := ToGRPCError(New(PasswordAlreadySet, "password already set"))

	st, ok := status.FromError(err)
	if !ok {
		t.Fatalf("expected grpc status")
	}

	if st.Code() != codes.AlreadyExists {
		t.Fatalf("grpc code mismatch: got %s", st.Code())
	}

	if got := ReasonFromGRPC(err); got != PasswordAlreadySet {
		t.Fatalf("reason mismatch: got %s want %s", got, PasswordAlreadySet)
	}
}

// TestToGRPCErrorPreservesContextTermination 防止并发聚合器主动取消 sibling RPC 时，
// database/sql 返回的 context 错误被兜底成 Internal，污染告警并掩盖真正的首个失败。
func TestToGRPCErrorPreservesContextTermination(t *testing.T) {
	tests := []struct {
		name string
		err  error
		code codes.Code
	}{
		{name: "canceled", err: context.Canceled, code: codes.Canceled},
		{name: "wrapped canceled", err: fmt.Errorf("query interrupted: %w", context.Canceled), code: codes.Canceled},
		{name: "deadline exceeded", err: context.DeadlineExceeded, code: codes.DeadlineExceeded},
		{name: "wrapped deadline exceeded", err: fmt.Errorf("query interrupted: %w", context.DeadlineExceeded), code: codes.DeadlineExceeded},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			if got := status.Code(ToGRPCError(test.err)); got != test.code {
				t.Fatalf("grpc code mismatch: got %s want %s", got, test.code)
			}
		})
	}
}

// TestToGRPCErrorSanitizesButPreservesCause 锁定 2026-07-12 排障改进：
// 非 xerr 错误对客户端仍固定脱敏成 internal error，但服务端能通过 CauseFromGRPC 取回原始错误链。
func TestToGRPCErrorSanitizesButPreservesCause(t *testing.T) {
	rootCause := errors.New("redis: connection refused")
	err := ToGRPCError(fmt.Errorf("acquire room lease: %w", rootCause))

	st, ok := status.FromError(err)
	if !ok {
		t.Fatalf("expected grpc status")
	}
	if st.Code() != codes.Internal || st.Message() != "internal error" {
		t.Fatalf("client-facing status must stay sanitized: code=%s message=%q", st.Code(), st.Message())
	}
	if err.Error() != st.Err().Error() {
		t.Fatalf("Error() must not leak cause: %q", err.Error())
	}

	cause := CauseFromGRPC(err)
	if cause == nil || !errors.Is(cause, rootCause) {
		t.Fatalf("cause chain lost: got %v", cause)
	}

	// 透传已有 status 的错误没有被脱敏，不应报告 cause。
	if got := CauseFromGRPC(status.Error(codes.NotFound, "room not found")); got != nil {
		t.Fatalf("passthrough status should have no cause, got %v", got)
	}
	if got := ToGRPCError(err); got != err {
		t.Fatalf("ToGRPCError must be idempotent on wrapped errors")
	}
}

func TestCatalogCoversDeclaredCodes(t *testing.T) {
	declared := declaredCodesFromSource(t)

	for code, name := range declared {
		if _, ok := catalog[code]; !ok {
			t.Fatalf("declared xerr code %s (%s) is missing from catalog", name, code)
		}
	}

	for code := range catalog {
		if _, ok := declared[code]; !ok {
			t.Fatalf("catalog registers undeclared xerr code %s", code)
		}
	}
}

func TestCatalogMappings(t *testing.T) {
	tests := []struct {
		name          string
		code          Code
		grpcCode      codes.Code
		httpStatus    int
		publicCode    string
		publicMessage string
	}{
		{
			name:          "business reason keeps canonical grpc code",
			code:          InvalidInviteCode,
			grpcCode:      codes.InvalidArgument,
			httpStatus:    httpStatusBadRequest,
			publicCode:    string(InvalidInviteCode),
			publicMessage: "invalid invite code",
		},
		{
			name:          "unavailable is exposed as upstream error",
			code:          Unavailable,
			grpcCode:      codes.Unavailable,
			httpStatus:    httpStatusBadGateway,
			publicCode:    "UPSTREAM_ERROR",
			publicMessage: "upstream service error",
		},
		{
			name:          "ledger state change uses aborted grpc but actionable http message",
			code:          LedgerConflict,
			grpcCode:      codes.Aborted,
			httpStatus:    httpStatusConflict,
			publicCode:    string(LedgerConflict),
			publicMessage: "wallet state changed, please retry",
		},
		{
			name:          "point withdrawal policy limit maps to http 429",
			code:          PointWithdrawalLimitReached,
			grpcCode:      codes.ResourceExhausted,
			httpStatus:    429,
			publicCode:    string(PointWithdrawalLimitReached),
			publicMessage: "withdrawal limit reached",
		},
		{
			name:          "point withdrawal date gate keeps actionable conflict",
			code:          PointWithdrawalDateNotAllowed,
			grpcCode:      codes.FailedPrecondition,
			httpStatus:    httpStatusConflict,
			publicCode:    string(PointWithdrawalDateNotAllowed),
			publicMessage: "withdrawal date is not allowed",
		},
		{
			name:          "country cooldown keeps actionable app message",
			code:          CountryChangeCooldown,
			grpcCode:      codes.FailedPrecondition,
			httpStatus:    httpStatusConflict,
			publicCode:    string(CountryChangeCooldown),
			publicMessage: "country change is cooling down",
		},
		{
			name:          "country identity lock tells user how to proceed",
			code:          CountryChangeIdentityLocked,
			grpcCode:      codes.PermissionDenied,
			httpStatus:    httpStatusForbidden,
			publicCode:    string(CountryChangeIdentityLocked),
			publicMessage: "You already have a host identity. Please contact customer service to change your country.",
		},
		{
			name:          "room mute tells user why mic up is blocked",
			code:          RoomUserMuted,
			grpcCode:      codes.PermissionDenied,
			httpStatus:    httpStatusForbidden,
			publicCode:    string(RoomUserMuted),
			publicMessage: "You have been muted and cannot join the mic.",
		},
		{
			name:          "registered device conflict exposes concrete login reason",
			code:          DeviceAlreadyRegistered,
			grpcCode:      codes.FailedPrecondition,
			httpStatus:    httpStatusConflict,
			publicCode:    string(DeviceAlreadyRegistered),
			publicMessage: "device already registered",
		},
		{
			name:          "cp conflict identifies current user",
			code:          CPAlreadyExistsSelf,
			grpcCode:      codes.FailedPrecondition,
			httpStatus:    httpStatusConflict,
			publicCode:    string(CPAlreadyExistsSelf),
			publicMessage: "You already have a CP",
		},
		{
			name:          "cp conflict identifies other party",
			code:          CPAlreadyExistsOther,
			grpcCode:      codes.FailedPrecondition,
			httpStatus:    httpStatusConflict,
			publicCode:    string(CPAlreadyExistsOther),
			publicMessage: "The other party already has a CP",
		},
		{
			name:          "vip anti kick keeps actionable forbidden code",
			code:          VIPAntiKick,
			grpcCode:      codes.PermissionDenied,
			httpStatus:    httpStatusForbidden,
			publicCode:    string(VIPAntiKick),
			publicMessage: "target user cannot be kicked",
		},
		{
			name:          "region mismatch keeps forbidden transport but exposes concrete app prompt",
			code:          RegionMismatch,
			grpcCode:      codes.PermissionDenied,
			httpStatus:    httpStatusForbidden,
			publicCode:    string(RegionMismatch),
			publicMessage: "不是同一个地区",
		},
		{
			name:          "room rps taken exposes late challenger prompt",
			code:          RoomRPSChallengeTaken,
			grpcCode:      codes.FailedPrecondition,
			httpStatus:    httpStatusConflict,
			publicCode:    string(RoomRPSChallengeTaken),
			publicMessage: "手慢啦，该 PK 已被其他人接单",
		},
		{
			name:          "room rps expired exposes actionable prompt",
			code:          RoomRPSChallengeExpired,
			grpcCode:      codes.FailedPrecondition,
			httpStatus:    httpStatusConflict,
			publicCode:    string(RoomRPSChallengeExpired),
			publicMessage: "猜拳已过期",
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			spec := SpecOf(test.code)
			if spec.GRPCCode != test.grpcCode ||
				spec.HTTPStatus != test.httpStatus ||
				spec.PublicCode != test.publicCode ||
				spec.PublicMessage != test.publicMessage {
				t.Fatalf("catalog mismatch: got %+v", spec)
			}
		})
	}
}

func TestCatalogDoesNotExposeBareConflictMessage(t *testing.T) {
	for _, code := range RegisteredCodes() {
		spec := SpecOf(code)
		if spec.PublicMessage == "conflict" {
			t.Fatalf("code %s exposes bare conflict message", code)
		}
	}
}

func declaredCodesFromSource(t *testing.T) map[Code]string {
	t.Helper()

	file, err := parser.ParseFile(token.NewFileSet(), "errors.go", nil, 0)
	if err != nil {
		t.Fatalf("parse errors.go: %v", err)
	}

	declared := make(map[Code]string)
	ast.Inspect(file, func(node ast.Node) bool {
		decl, ok := node.(*ast.GenDecl)
		if !ok || decl.Tok != token.CONST {
			return true
		}

		for _, spec := range decl.Specs {
			valueSpec, ok := spec.(*ast.ValueSpec)
			if !ok {
				continue
			}
			for index, name := range valueSpec.Names {
				if index >= len(valueSpec.Values) {
					continue
				}
				literal, ok := valueSpec.Values[index].(*ast.BasicLit)
				if !ok || literal.Kind != token.STRING {
					continue
				}
				value, err := strconv.Unquote(literal.Value)
				if err != nil {
					t.Fatalf("unquote %s: %v", literal.Value, err)
				}
				declared[Code(value)] = name.Name
			}
		}

		return false
	})

	return declared
}
