package oncall

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"

	"github.com/stretchr/testify/require"
)

// rosterServer serves current-oncall for a single team, counting requests.
func rosterServer(t *testing.T, body string) (*httptest.Server, *atomic.Int32, *string) {
	t.Helper()
	var calls atomic.Int32
	var lastAuth string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		lastAuth = r.Header.Get("Authorization")
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, body)
	}))
	t.Cleanup(srv.Close)
	return srv, &calls, &lastAuth
}

func newTestClient(t *testing.T, baseURL, defaultTeam string) *Client {
	t.Helper()
	c, err := New(Config{BaseURL: baseURL, APIKey: "test-key", DefaultTeam: defaultTeam}, nil)
	require.NoError(t, err)
	return c
}

func TestResolveAssigneeUsesPrimaryLane(t *testing.T) {
	srv, calls, auth := rosterServer(t, `{
		"team_id": 7,
		"current_engineer_emails": ["primary@pingcap.com", " second@pingcap.com "],
		"backup_engineer_emails": ["backup@pingcap.com"]
	}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{TeamLabel: "tikv"})
	require.NoError(t, err)
	require.Equal(t, "primary@pingcap.com", got.Assignee)
	require.Equal(t, "tikv", got.Team)
	require.Equal(t, OutcomePrimary, got.Outcome)
	require.Equal(t, []string{"primary@pingcap.com", "second@pingcap.com"}, got.Candidates)
	require.Equal(t, int32(1), calls.Load())
	require.Equal(t, "Bearer test-key", *auth)
}

func TestResolveAssigneeLabelNoneSuppressesAssignment(t *testing.T) {
	srv, calls, _ := rosterServer(t, `{"current_engineer_emails": ["primary@pingcap.com"]}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{
		TeamLabel:   "tikv",
		AssignLabel: "None",
	})
	require.NoError(t, err)
	require.Empty(t, got.Assignee)
	require.Equal(t, OutcomeAssignNone, got.Outcome)
	require.Equal(t, int32(0), calls.Load(), "must not query the oncall service")
}

func TestResolveAssigneeLabelEmailNamesOwner(t *testing.T) {
	srv, calls, _ := rosterServer(t, `{"current_engineer_emails": ["primary@pingcap.com"]}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{
		TeamLabel:   "tikv",
		AssignLabel: " alice@pingcap.com ",
	})
	require.NoError(t, err)
	require.Equal(t, "alice@pingcap.com", got.Assignee)
	require.Equal(t, OutcomeAssignLabel, got.Outcome)
	require.Equal(t, []string{"alice@pingcap.com"}, got.Candidates)
	require.Equal(t, int32(0), calls.Load(), "must not query the oncall service")
}

func TestResolveAssigneeLabelHonoredWhenDisabled(t *testing.T) {
	c, err := New(Config{}, nil) // no base URL: oncall integration disabled
	require.NoError(t, err)

	none, err := c.ResolveAssignee(context.Background(), map[string]string{AssignLabel: "none"})
	require.NoError(t, err)
	require.Equal(t, OutcomeAssignNone, none.Outcome)

	email, err := c.ResolveAssignee(context.Background(), map[string]string{AssignLabel: "bob@pingcap.com"})
	require.NoError(t, err)
	require.Equal(t, "bob@pingcap.com", email.Assignee)
	require.Equal(t, OutcomeAssignLabel, email.Outcome)
}

func TestResolveAssigneeIgnoresNonEmailNonNoneLabel(t *testing.T) {
	srv, calls, _ := rosterServer(t, `{"current_engineer_emails": ["primary@pingcap.com"]}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{
		TeamLabel:   "tikv",
		AssignLabel: "garbage",
	})
	require.NoError(t, err)
	require.Equal(t, "primary@pingcap.com", got.Assignee)
	require.Equal(t, OutcomePrimary, got.Outcome)
	require.Equal(t, int32(1), calls.Load())
}

func TestResolveAssigneeIgnoresAssignLabelWhenConfigured(t *testing.T) {
	srv, _, _ := rosterServer(t, `{"current_engineer_emails": ["primary@pingcap.com"]}`)
	c := newTestClient(t, srv.URL, "")
	c.config.IgnoreAssignLabel = true

	// Both the none opt-out and an explicit email are dropped: the alert
	// routes through the duty schedule as if the label were absent.
	none, err := c.ResolveAssignee(context.Background(), map[string]string{
		TeamLabel:   "tikv",
		AssignLabel: "none",
	})
	require.NoError(t, err)
	require.Equal(t, "primary@pingcap.com", none.Assignee)
	require.Equal(t, OutcomePrimary, none.Outcome)

	email, err := c.ResolveAssignee(context.Background(), map[string]string{
		TeamLabel:   "tikv",
		AssignLabel: "alice@pingcap.com",
	})
	require.NoError(t, err)
	require.Equal(t, "primary@pingcap.com", email.Assignee)
	require.Equal(t, OutcomePrimary, email.Outcome)
}

func TestResolveAssigneeFallsBackToBackupLane(t *testing.T) {
	// Managers are present but must not win while the backup lane is staffed.
	srv, _, _ := rosterServer(t, `{
		"current_engineer_emails": [],
		"backup_engineer_emails": ["backup@pingcap.com"],
		"manager_emails": ["manager@pingcap.com"]
	}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{TeamLabel: "tikv"})
	require.NoError(t, err)
	require.Equal(t, "backup@pingcap.com", got.Assignee)
	require.Equal(t, OutcomeBackup, got.Outcome)
}

func TestResolveAssigneeEscalatesToManagerOnScheduleGap(t *testing.T) {
	srv, _, _ := rosterServer(t, `{
		"current_engineer_emails": [],
		"backup_engineer_emails": [],
		"manager_emails": ["boss@pingcap.com", " deputy@pingcap.com "]
	}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{TeamLabel: "tikv"})
	require.NoError(t, err)
	require.Equal(t, "boss@pingcap.com", got.Assignee)
	require.Equal(t, OutcomeManager, got.Outcome)
	require.Equal(t, []string{"boss@pingcap.com", "deputy@pingcap.com"}, got.Candidates)
	require.Equal(t, "tikv", got.Team)
}

func TestResolveAssigneeReportsScheduleGap(t *testing.T) {
	srv, _, _ := rosterServer(t, `{
		"current_engineer_emails": [],
		"backup_engineer_emails": [],
		"manager_emails": []
	}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{TeamLabel: "tikv"})
	require.NoError(t, err)
	require.Empty(t, got.Assignee)
	require.Equal(t, OutcomeScheduleGap, got.Outcome)
	require.Equal(t, "tikv", got.Team)
}

// An oncall service that predates manager_emails omits the key entirely; the
// resolver must degrade to the old primary/backup/gap behaviour, not panic.
func TestResolveAssigneeWithoutManagerFieldReportsScheduleGap(t *testing.T) {
	srv, _, _ := rosterServer(t, `{"current_engineer_emails": [], "backup_engineer_emails": []}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{TeamLabel: "tikv"})
	require.NoError(t, err)
	require.Empty(t, got.Assignee)
	require.Equal(t, OutcomeScheduleGap, got.Outcome)
}

func TestResolveAssigneeUsesDefaultTeamWithoutLabel(t *testing.T) {
	var gotPath string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		fmt.Fprint(w, `{"current_engineer_emails": ["fallback@pingcap.com"]}`)
	}))
	t.Cleanup(srv.Close)
	c := newTestClient(t, srv.URL, "cloud-infra")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{"alertname": "TiDBDown"})
	require.NoError(t, err)
	require.Equal(t, "fallback@pingcap.com", got.Assignee)
	require.Equal(t, "cloud-infra", got.Team)
	require.Equal(t, "/api/v1/teams/cloud-infra/current-oncall", gotPath)
}

func TestResolveAssigneeWithoutTeamOrDefault(t *testing.T) {
	srv, calls, _ := rosterServer(t, `{"current_engineer_emails": ["nobody@pingcap.com"]}`)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{"alertname": "TiDBDown"})
	require.NoError(t, err)
	require.Empty(t, got.Assignee)
	require.Equal(t, OutcomeNoTeam, got.Outcome)
	require.Zero(t, calls.Load(), "an unroutable alert must not call the oncall service")
}

func TestUnknownTeamIsTerminal(t *testing.T) {
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		http.Error(w, `{"error":"team does not exist"}`, http.StatusNotFound)
	}))
	t.Cleanup(srv.Close)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{TeamLabel: "tikv-typo"})
	require.ErrorIs(t, err, ErrTeamNotFound)
	require.ErrorIs(t, err, ErrTerminal, "a typo in a team name is not fixed by retrying")
	require.Equal(t, OutcomeUnknownTeam, got.Outcome)
	require.Empty(t, got.Assignee)

	// A terminal answer is still cheap to ask for again later; it is the caller
	// that must not spin on it.
	require.Equal(t, int32(1), calls.Load())
}

func TestRefusedRequestIsTerminalButRateLimitIsNot(t *testing.T) {
	cases := []struct {
		status   int
		terminal bool
	}{
		{http.StatusUnauthorized, true},
		{http.StatusForbidden, true},
		{http.StatusBadRequest, true},
		{http.StatusTooManyRequests, false},
		{http.StatusBadGateway, false},
		{http.StatusServiceUnavailable, false},
	}
	for _, tc := range cases {
		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			http.Error(w, "nope", tc.status)
		}))
		c := newTestClient(t, srv.URL, "")

		_, err := c.Roster(context.Background(), "tikv")
		require.Error(t, err)
		if tc.terminal {
			require.ErrorIs(t, err, ErrTerminal, "HTTP %d", tc.status)
		} else {
			require.NotErrorIs(t, err, ErrTerminal, "HTTP %d must stay retryable", tc.status)
		}
		srv.Close()
	}
}

func TestResolveAssigneeFailsOpenOnServiceError(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "boom", http.StatusInternalServerError)
	}))
	t.Cleanup(srv.Close)
	c := newTestClient(t, srv.URL, "")

	got, err := c.ResolveAssignee(context.Background(), map[string]string{TeamLabel: "tikv"})
	require.Error(t, err, "the caller logs this, but still delivers the alert")
	require.Empty(t, got.Assignee)
	require.Equal(t, OutcomeLookupError, got.Outcome)
	require.Equal(t, "tikv", got.Team)
}

func TestDisabledClientResolvesToNobody(t *testing.T) {
	c, err := New(Config{}, nil)
	require.NoError(t, err)
	require.False(t, c.Enabled())

	got, err := c.ResolveAssignee(context.Background(), map[string]string{TeamLabel: "tikv"})
	require.NoError(t, err)
	require.Empty(t, got.Assignee)
	require.Equal(t, OutcomeDisabled, got.Outcome)

	_, err = c.Roster(context.Background(), "tikv")
	require.ErrorIs(t, err, ErrDisabled)
}

func TestCurrentOncallCachesPerTeam(t *testing.T) {
	srv, calls, _ := rosterServer(t, `{"current_engineer_emails": ["primary@pingcap.com"]}`)
	c := newTestClient(t, srv.URL, "")

	// Repeat firings of the same alert must not re-hit the oncall service.
	for range 3 {
		_, err := c.Roster(context.Background(), "tikv")
		require.NoError(t, err)
	}
	require.Equal(t, int32(1), calls.Load())

	// A different team is a separate cache entry.
	_, err := c.Roster(context.Background(), "pd")
	require.NoError(t, err)
	require.Equal(t, int32(2), calls.Load())
}

func TestCurrentOncallDoesNotCacheFailures(t *testing.T) {
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if calls.Add(1) == 1 {
			http.Error(w, "boom", http.StatusBadGateway)
			return
		}
		fmt.Fprint(w, `{"current_engineer_emails": ["primary@pingcap.com"]}`)
	}))
	t.Cleanup(srv.Close)
	c := newTestClient(t, srv.URL, "")

	_, err := c.Roster(context.Background(), "tikv")
	require.Error(t, err)

	roster, err := c.Roster(context.Background(), "tikv")
	require.NoError(t, err)
	require.Equal(t, []string{"primary@pingcap.com"}, roster.Primary)
}

func TestNewRejectsInvalidBaseURL(t *testing.T) {
	_, err := New(Config{BaseURL: "not-a-url"}, nil)
	require.Error(t, err)
}

func TestTeamRefPrefersLabelOverDefault(t *testing.T) {
	c := newTestClient(t, "https://oncall.example.com", "cloud-infra")

	require.Equal(t, "tikv", c.TeamRef(map[string]string{TeamLabel: "tikv"}))
	require.Equal(t, "cloud-infra", c.TeamRef(map[string]string{TeamLabel: "  "}))
	require.Equal(t, "cloud-infra", c.TeamRef(nil))
}

func TestTeamRefRemapsTiKVUnifiedPageToTiKVCritical(t *testing.T) {
	c := newTestClient(t, "https://oncall.example.com", "")

	// A tikv_unified page redirects to tikv_critical.
	require.Equal(t, "tikv_critical", c.TeamRef(map[string]string{
		TeamLabel:        "tikv_unified",
		"response_class": "page",
	}))
	// The severity=critical fallback also counts as a page.
	require.Equal(t, "tikv_critical", c.TeamRef(map[string]string{
		TeamLabel:  "tikv_unified",
		"severity": "critical",
	}))
	// Non-page tikv_unified alerts keep their team.
	require.Equal(t, "tikv_unified", c.TeamRef(map[string]string{
		TeamLabel:        "tikv_unified",
		"response_class": "ticket",
	}))
	require.Equal(t, "tikv_unified", c.TeamRef(map[string]string{
		TeamLabel:  "tikv_unified",
		"severity": "major",
	}))
	// A different team is untouched even when paging.
	require.Equal(t, "tikv", c.TeamRef(map[string]string{
		TeamLabel:        "tikv",
		"response_class": "page",
	}))
}

func TestTeamRefRemapsWorkerTiDBComponentToTiDBExecutor(t *testing.T) {
	c := newTestClient(t, "https://oncall.example.com", "")

	// A worker-tidb component wins over whatever oncall_team the rule set,
	// regardless of tier.
	require.Equal(t, "tidb_executor", c.TeamRef(map[string]string{
		"component":      "worker-tidb",
		TeamLabel:        "tikv_unified",
		"response_class": "page",
	}))
	require.Equal(t, "tidb_executor", c.TeamRef(map[string]string{
		"component": "worker-tidb",
	}))
	require.Equal(t, "tidb_executor", c.TeamRef(map[string]string{
		"component":      "worker-tidb",
		"response_class": "logging",
	}))
	// A different component is untouched.
	require.Equal(t, "tikv", c.TeamRef(map[string]string{
		"component": "worker-tikv",
		TeamLabel:   "tikv",
	}))
}
