// Package oncall is the client for the oncall v2 service (aisupport
// `services/oncall`), the system of record for who is on duty. Today it
// resolves an alert's owner from the duty schedule, so a new alert is created
// with a named engineer instead of landing in the main group unowned; when
// phone calling moves into that service, the outbound call goes through here
// too.
//
// Everything here is fail-open: an oncall service outage must never stop an
// alert from being delivered, it only leaves the alert unassigned for a human
// to pick up.
package oncall

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"path"
	"strings"
	"sync"
	"time"
)

// TeamLabel is the alertmanager label carrying the owning on-call team, the
// same label jiraport routes on today (`labels.oncall_team`).
const TeamLabel = "oncall_team"

// AssignLabel is the alertmanager label that overrides on-call resolution, the
// same label jiraport honors today (`labels.assign`). Value "none" leaves the
// alert unassigned; an email assigns that person directly. Any other value is
// ignored and normal on-call routing applies.
const AssignLabel = "assign"

// AssignNone is the AssignLabel value that suppresses assignment entirely.
const AssignNone = "none"

// Hardcoded team overrides — hacks for rules whose oncall_team/component does
// not name the rotation on duty. Drop an entry once its rules are fixed.
const (
	teamTiKVUnified     = "tikv_unified" // page tier only -> tikv_critical
	teamTiKVCritical    = "tikv_critical"
	componentWorkerTiDB = "worker-tidb" // any tier -> tidb_executor
	teamTiDBExecutor    = "tidb_executor"
)

// Label keys duplicated from pkg/alert to keep this client import-cycle free.
const (
	componentLabel    = "component"
	responseClassPage = "page"
)

// Outcome values, used as-is for the lookup metric's label.
const (
	OutcomePrimary     = "primary"      // assigned from the primary lane
	OutcomeBackup      = "backup"       // primary lane was empty, assigned from backup
	OutcomeManager     = "manager"      // both lanes empty, escalated to a team manager
	OutcomeDisabled    = "disabled"     // no oncall service configured
	OutcomeNoTeam      = "no_team"      // no oncall_team label and no default team
	OutcomeScheduleGap = "schedule_gap" // team exists but nobody is on call
	OutcomeUnknownTeam = "unknown_team" // routed to a team the service does not have
	OutcomeLookupError = "lookup_error" // oncall service unreachable or erroring
	OutcomeAssignNone  = "assign_none"  // an `assign: none` label suppressed assignment
	OutcomeAssignLabel = "assign_label" // an `assign: <email>` label named the owner
)

var (
	ErrDisabled = errors.New("oncall service integration is not configured")

	// ErrTerminal marks a lookup that retrying cannot fix — a team that does
	// not exist, or a request the service refuses. Callers record it and move
	// on instead of spending a retry budget on an answer that will not change.
	ErrTerminal = errors.New("oncall lookup cannot succeed by retrying")

	// ErrTeamNotFound is the common terminal case: the alert routes to a team
	// the oncall service has never heard of, usually a typo in an alert rule's
	// oncall_team label or in default_team.
	ErrTeamNotFound = fmt.Errorf("%w: team does not exist", ErrTerminal)
)

const (
	defaultTimeout = 5 * time.Second
	// Rotations turn over on the order of hours, so a short TTL is enough to
	// keep alertmanager's repeat_interval re-fires off the oncall service
	// while still picking up a handover or an override within a minute.
	defaultCacheTTL = time.Minute
)

type Config struct {
	BaseURL string
	APIKey  string
	// DefaultTeam is consulted when an alert carries no oncall_team label.
	// Empty means such alerts stay unassigned.
	DefaultTeam string
	Timeout     time.Duration
	CacheTTL    time.Duration
	// IgnoreAssignLabel disables the per-rule `assign` label override, so every
	// alert routes through the duty schedule regardless of the label. Used in
	// non-prod to keep production rules' opt-outs and emails out of a test
	// deployment.
	IgnoreAssignLabel bool
}

// Roster is one team's on-call lineup at one moment. Managers do not vary with
// time; they are the standing escalation target when both lanes are empty.
type Roster struct {
	Primary  []string
	Backup   []string
	Managers []string
}

// Assignment is the result of resolving an alert's owner. Assignee is set iff
// Outcome is OutcomePrimary, OutcomeBackup or OutcomeManager.
type Assignment struct {
	Assignee   string
	Team       string
	Candidates []string
	Outcome    string
}

type Client struct {
	config Config
	http   *http.Client

	cacheMu sync.RWMutex
	cache   map[string]cachedRoster
}

type cachedRoster struct {
	roster    Roster
	expiresAt time.Time
}

func New(config Config, httpClient *http.Client) (*Client, error) {
	if config.BaseURL != "" {
		u, err := url.Parse(config.BaseURL)
		if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
			return nil, fmt.Errorf("invalid oncall base_url %q", config.BaseURL)
		}
	}
	if config.Timeout <= 0 {
		config.Timeout = defaultTimeout
	}
	if config.CacheTTL <= 0 {
		config.CacheTTL = defaultCacheTTL
	}
	if httpClient == nil {
		httpClient = &http.Client{Timeout: config.Timeout}
	}
	return &Client{
		config: config,
		http:   httpClient,
		cache:  make(map[string]cachedRoster),
	}, nil
}

func (c *Client) Enabled() bool {
	return c != nil && c.config.BaseURL != ""
}

// TeamRef returns the team an alert's labels route to, falling back to the
// configured default team. Empty means the alert cannot be routed.
func (c *Client) TeamRef(labels map[string]string) string {
	if c == nil {
		return ""
	}
	team := strings.TrimSpace(labels[TeamLabel])
	if team == "" {
		team = strings.TrimSpace(c.config.DefaultTeam)
	}
	return overrideTeam(team, labels)
}

// overrideTeam applies the hardcoded remaps above, keyed off the alert's labels.
func overrideTeam(team string, labels map[string]string) string {
	// Component wins over whatever oncall_team the rule set.
	if strings.EqualFold(strings.TrimSpace(labels[componentLabel]), componentWorkerTiDB) {
		return teamTiDBExecutor
	}
	if team == teamTiKVUnified && isPage(labels) {
		return teamTiKVCritical
	}
	return team
}

// isPage classifies an alert from its labels the way webhook.processAlert does:
// response_class wins, else severity == critical pages. Later classifier
// downgrades are intentionally not reproduced — the hack keys off declared tier.
func isPage(labels map[string]string) bool {
	if class := strings.TrimSpace(labels["response_class"]); class != "" {
		return class == responseClassPage
	}
	return strings.EqualFold(strings.TrimSpace(labels["severity"]), "critical")
}

// ResolveAssignee picks the owner for an alert from its labels. The returned
// error is only for logging: the Assignment is always usable, carrying an
// empty Assignee and an Outcome explaining why when resolution failed.
func (c *Client) ResolveAssignee(ctx context.Context, labels map[string]string) (Assignment, error) {
	// An explicit `assign` label on the alert rule overrides on-call routing,
	// matching jiraport. This is honored even when the oncall service is
	// disabled: it needs no lookup. Non-prod deployments disable it so that
	// production rules' opt-outs and emails do not leak into a test env.
	if override := strings.TrimSpace(labels[AssignLabel]); override != "" && !c.config.IgnoreAssignLabel {
		if strings.EqualFold(override, AssignNone) {
			return Assignment{Outcome: OutcomeAssignNone}, nil
		}
		if strings.Contains(override, "@") {
			return Assignment{
				Assignee:   override,
				Candidates: []string{override},
				Outcome:    OutcomeAssignLabel,
			}, nil
		}
	}

	if !c.Enabled() {
		return Assignment{Outcome: OutcomeDisabled}, nil
	}

	team := c.TeamRef(labels)
	if team == "" {
		return Assignment{Outcome: OutcomeNoTeam}, nil
	}

	roster, err := c.Roster(ctx, team)
	if err != nil {
		outcome := OutcomeLookupError
		if errors.Is(err, ErrTeamNotFound) {
			outcome = OutcomeUnknownTeam
		}
		return Assignment{Team: team, Outcome: outcome}, err
	}

	// Primary lane first; a gap there falls through to the backup lane, and a
	// gap in both escalates to the team's managers rather than leaving the
	// alert unowned. A manager is a worse page than a scheduled oncaller — the
	// OutcomeManager metric label is what says the rotation has a hole to fix.
	lane, outcome := roster.Primary, OutcomePrimary
	if len(lane) == 0 {
		lane, outcome = roster.Backup, OutcomeBackup
	}
	if len(lane) == 0 {
		lane, outcome = roster.Managers, OutcomeManager
	}
	if len(lane) == 0 {
		return Assignment{Team: team, Outcome: OutcomeScheduleGap}, nil
	}

	return Assignment{
		Assignee:   lane[0],
		Team:       team,
		Candidates: lane,
		Outcome:    outcome,
	}, nil
}

// Roster returns who is on duty for teamRef, which is either a numeric team id
// or a unique team slug. Results are cached for CacheTTL, including empty ones
// — a schedule gap is a real answer, not a failure.
func (c *Client) Roster(ctx context.Context, teamRef string) (Roster, error) {
	if !c.Enabled() {
		return Roster{}, ErrDisabled
	}
	teamRef = strings.TrimSpace(teamRef)
	if teamRef == "" {
		return Roster{}, errors.New("empty team ref")
	}
	if roster, ok := c.cachedRoster(teamRef); ok {
		return roster, nil
	}

	u, err := url.Parse(c.config.BaseURL)
	if err != nil {
		return Roster{}, fmt.Errorf("parse oncall base url: %w", err)
	}
	u.Path = path.Join(u.Path, "/api/v1/teams", teamRef, "current-oncall")

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return Roster{}, fmt.Errorf("build current-oncall request: %w", err)
	}
	// Empty on the internal listener, which authenticates nothing.
	if c.config.APIKey != "" {
		req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return Roster{}, fmt.Errorf("query current oncall for team %s: %w", teamRef, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
		message := strings.TrimSpace(string(body))
		if message == "" {
			message = resp.Status
		}
		if resp.StatusCode == http.StatusNotFound {
			return Roster{}, fmt.Errorf("%w: %s (HTTP 404: %s)", ErrTeamNotFound, teamRef, message)
		}
		// A refused request is a configuration problem — a bad key, a malformed
		// team ref. Retrying it eight times fixes nothing and buries the real
		// error. Rate limiting is the exception: that one does pass.
		if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests {
			return Roster{}, fmt.Errorf("%w: current-oncall lookup for team %s refused: HTTP %d: %s",
				ErrTerminal, teamRef, resp.StatusCode, message)
		}
		return Roster{}, fmt.Errorf("current-oncall lookup for team %s failed: HTTP %d: %s", teamRef, resp.StatusCode, message)
	}

	var payload currentOncallResponse
	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
		return Roster{}, fmt.Errorf("decode current-oncall response: %w", err)
	}

	roster := Roster{
		Primary:  cleanEmails(payload.CurrentEngineerEmails),
		Backup:   cleanEmails(payload.BackupEngineerEmails),
		Managers: cleanEmails(payload.ManagerEmails),
	}
	c.storeRoster(teamRef, roster)
	return roster, nil
}

// currentOncallResponse mirrors the oncall v2 contract
// (packages/sdk-go/contracts/api/oncall.CurrentOncallResponse). It is redeclared
// here because alertstore is built as a standalone module.
type currentOncallResponse struct {
	CurrentEngineerEmails []string `json:"current_engineer_emails"`
	BackupEngineerEmails  []string `json:"backup_engineer_emails"`
	ManagerEmails         []string `json:"manager_emails"`
}

func (c *Client) cachedRoster(teamRef string) (Roster, bool) {
	c.cacheMu.RLock()
	entry, ok := c.cache[teamRef]
	c.cacheMu.RUnlock()
	if !ok || time.Now().After(entry.expiresAt) {
		return Roster{}, false
	}
	return entry.roster, true
}

func (c *Client) storeRoster(teamRef string, roster Roster) {
	c.cacheMu.Lock()
	c.cache[teamRef] = cachedRoster{
		roster:    roster,
		expiresAt: time.Now().Add(c.config.CacheTTL),
	}
	c.cacheMu.Unlock()
}

// PageRequest is the payload for the oncall service's POST /api/v1/page
// endpoint: one call that sends a Feishu message, makes it urgent, and phones
// the user.
type PageRequest struct {
	UserEmail string `json:"user_email"`
	Reason    string `json:"reason"`
}

// PageResult mirrors the oncall service's page response.
type PageResult struct {
	MessageID   string `json:"message_id,omitempty"`
	PhoneNumber string `json:"phone_number,omitempty"`
	CallMethod  string `json:"call_method,omitempty"`
	CallError   string `json:"call_error,omitempty"`
	UrgentError string `json:"urgent_error,omitempty"`
}

// Page calls the oncall service's page API to reach a person by Feishu message,
// urgent buzz, and phone call. Best-effort by nature: the oncall service itself
// treats the phone call and buzz as best-effort, so a nil error means the
// message was sent, not necessarily that the phone rang.
func (c *Client) Page(ctx context.Context, req PageRequest) (*PageResult, error) {
	if !c.Enabled() {
		return nil, ErrDisabled
	}
	if req.UserEmail == "" {
		return nil, fmt.Errorf("page: user_email is required")
	}

	u, err := url.Parse(c.config.BaseURL)
	if err != nil {
		return nil, fmt.Errorf("parse oncall base url: %w", err)
	}
	u.Path = path.Join(u.Path, "/api/v1/page")

	body, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("marshal page request: %w", err)
	}

	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(body)))
	if err != nil {
		return nil, fmt.Errorf("build page request: %w", err)
	}
	httpReq.Header.Set("Content-Type", "application/json")
	if c.config.APIKey != "" {
		httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey)
	}

	resp, err := c.http.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("page %s: %w", req.UserEmail, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
		message := strings.TrimSpace(string(respBody))
		if message == "" {
			message = resp.Status
		}
		return nil, fmt.Errorf("page %s: HTTP %d: %s", req.UserEmail, resp.StatusCode, message)
	}

	var result PageResult
	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode page response: %w", err)
	}
	return &result, nil
}

func cleanEmails(raw []string) []string {
	out := make([]string, 0, len(raw))
	for _, e := range raw {
		if e = strings.TrimSpace(e); e != "" {
			out = append(out, e)
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}
