package integration_test

import (
	"context"
	"testing"
	"time"

	"galaxy/integration/testenv"
	usermodel "galaxy/model/user"
	"galaxy/transcoder"
)

// TestSessionRevoke_SubsequentRequestsRejected revokes the caller's
// session through the user surface (signed authenticated-edge end-to-end) and
// asserts that subsequent authenticated calls bound to that session
// are rejected by gateway.
func TestSessionRevoke_SubsequentRequestsRejected(t *testing.T) {
	plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
	defer cancel()

	sess := testenv.RegisterSession(t, plat, "pilot+revoke@example.com")
	gw, err := sess.DialAuthenticated(ctx, plat)
	if err != nil {
		t.Fatalf("dial: %v", err)
	}
	defer gw.Close()

	// Sanity: the authenticated path works before revoke.
	getPayload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
	if err != nil {
		t.Fatalf("encode get-account payload: %v", err)
	}
	if _, err := gw.Execute(ctx, usermodel.MessageTypeGetMyAccount, getPayload, testenv.ExecuteOptions{}); err != nil {
		t.Fatalf("pre-revoke call failed: %v", err)
	}

	// Revoke own session through signed authenticated-edge call.
	revokePayload, err := transcoder.RevokeMySessionRequestToPayload(&usermodel.RevokeMySessionRequest{
		DeviceSessionID: sess.DeviceSessionID,
	})
	if err != nil {
		t.Fatalf("encode revoke payload: %v", err)
	}
	revokeResult, err := gw.Execute(ctx, usermodel.MessageTypeRevokeMySession, revokePayload, testenv.ExecuteOptions{})
	if err != nil {
		t.Fatalf("revoke: %v", err)
	}
	if revokeResult.ResultCode != "ok" {
		t.Fatalf("revoke result_code = %q, want ok", revokeResult.ResultCode)
	}

	// Authenticated requests must now be rejected. Allow up to 2s
	// for the session-invalidation push frame to propagate to gateway
	// and close any cached state.
	deadline := time.Now().Add(2 * time.Second)
	var lastErr error
	for time.Now().Before(deadline) {
		_, lastErr = gw.Execute(ctx, usermodel.MessageTypeGetMyAccount, getPayload, testenv.ExecuteOptions{})
		if lastErr != nil {
			break
		}
		time.Sleep(100 * time.Millisecond)
	}
	if lastErr == nil {
		t.Fatalf("post-revoke call still succeeded; expected rejection")
	}
	// Gateway maps a revoked session to FailedPrecondition ("device
	// session is revoked"); a session that vanished from the cache
	// before the call lands as Unauthenticated. Either is a correct
	// rejection.
	if !testenv.IsFailedPrecondition(lastErr) && !testenv.IsUnauthenticated(lastErr) {
		t.Fatalf("post-revoke status: %v", lastErr)
	}
}

// TestSessionRevoke_RejectsForeignSession checks that a caller cannot
// revoke a session that belongs to a different user. Backend returns
// the same shape as a missing session (no foreign-id probing).
func TestSessionRevoke_RejectsForeignSession(t *testing.T) {
	plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	owner := testenv.RegisterSession(t, plat, "owner+foreign@example.com")
	attacker := testenv.RegisterSession(t, plat, "attacker+foreign@example.com")

	attackerGW, err := attacker.DialAuthenticated(ctx, plat)
	if err != nil {
		t.Fatalf("dial attacker: %v", err)
	}
	defer attackerGW.Close()

	revokePayload, err := transcoder.RevokeMySessionRequestToPayload(&usermodel.RevokeMySessionRequest{
		DeviceSessionID: owner.DeviceSessionID,
	})
	if err != nil {
		t.Fatalf("encode revoke payload: %v", err)
	}
	result, err := attackerGW.Execute(ctx, usermodel.MessageTypeRevokeMySession, revokePayload, testenv.ExecuteOptions{})
	if err != nil {
		t.Fatalf("attacker revoke: %v", err)
	}
	if result.ResultCode == "ok" {
		t.Fatalf("attacker revoke result_code = ok, want a not-found error")
	}
	// Decoded error envelope must carry the not-found code so attackers
	// see the same shape as a genuinely missing session.
	errResp, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
	if err != nil {
		t.Fatalf("decode error: %v", err)
	}
	// Backend's user-side handlers stamp 404 responses with
	// `httperr.CodeNotFound = "not_found"`; the gateway forwards a
	// non-empty code as-is and only synthesises `subject_not_found`
	// when the upstream payload omits the code field. Both shapes
	// satisfy the "no foreign-id probing" contract — the attacker
	// learns the same thing for a missing session and a session that
	// belongs to someone else.
	if code := errResp.Error.Code; code != "not_found" && code != "subject_not_found" {
		t.Fatalf("error.code = %q, want not_found or subject_not_found", code)
	}
}

// TestSessionRevoke_RevokeAll covers the bulk logout path. Two
// sessions for the same user, then revoke-all, then both sessions
// must reject authenticated traffic.
func TestSessionRevoke_RevokeAll(t *testing.T) {
	plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
	defer cancel()

	const email = "pilot+revoke-all@example.com"
	first := testenv.RegisterSession(t, plat, email)
	second := testenv.RegisterSession(t, plat, email)

	firstGW, err := first.DialAuthenticated(ctx, plat)
	if err != nil {
		t.Fatalf("dial first: %v", err)
	}
	defer firstGW.Close()

	revokeAllPayload, err := transcoder.RevokeAllMySessionsRequestToPayload(&usermodel.RevokeAllMySessionsRequest{})
	if err != nil {
		t.Fatalf("encode revoke-all payload: %v", err)
	}
	result, err := firstGW.Execute(ctx, usermodel.MessageTypeRevokeAllMySessions, revokeAllPayload, testenv.ExecuteOptions{})
	if err != nil {
		t.Fatalf("revoke-all: %v", err)
	}
	if result.ResultCode != "ok" {
		t.Fatalf("revoke-all result_code = %q, want ok", result.ResultCode)
	}

	resp, err := transcoder.PayloadToRevokeAllMySessionsResponse(result.PayloadBytes)
	if err != nil {
		t.Fatalf("decode revoke-all payload: %v", err)
	}
	if resp.Summary.RevokedCount != 2 {
		t.Fatalf("summary.revoked_count = %d, want 2 (sessions: %s, %s)", resp.Summary.RevokedCount, first.DeviceSessionID, second.DeviceSessionID)
	}
}
