package api

import (
	"context"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"

	"github.com/tidbcloud/alertstore/pkg/agent"
	"github.com/tidbcloud/alertstore/pkg/alert"
	"github.com/tidbcloud/alertstore/pkg/timeline"
)

// @Summary	Alert counts per sidebar view
// @Id			alertCounts
// @Tags		Alerts
// @Produce	json
// @Success	200	{object}	alert.AlertCounts
// @Failure	500	{object}	ErrorResponse
// @Router		/bff/alerts/counts [get]
func (s *Server) alertCounts(c *gin.Context) {
	counts, err := s.alertStore.Counts(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	c.JSON(http.StatusOK, counts)
}

// @Summary	Distinct oncall teams
// @Id			alertTeams
// @Tags		Alerts
// @Produce	json
// @Success	200	{array}		string
// @Failure	500	{object}	ErrorResponse
// @Router		/bff/alerts/teams [get]
func (s *Server) alertTeams(c *gin.Context) {
	teams, err := s.alertStore.AllOncallTeams(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	c.JSON(http.StatusOK, teams)
}

// alertNow backs the Dashboards page's "Now" strip. It is deliberately a
// separate endpoint from alertCounts rather than an extension of it:
// AlertCounts is the sidebar's all-classification, all-state nav badge
// count, read by main.ts alone, while this is page-scoped, current-state
// only, and shaped around the response-deadline alarm the strip exists to
// show — bolting that onto AlertCounts would mean every sidebar consumer
// carrying fields it never reads.
//
//	@Summary	Current alert state for the Dashboards page's "Now" strip
//	@Id			alertNow
//	@Tags		Alerts
//	@Produce	json
//	@Success	200	{object}	alert.NowStats
//	@Failure	500	{object}	ErrorResponse
//	@Router		/bff/alerts/now [get]
func (s *Server) alertNow(c *gin.Context) {
	now, err := s.alertStore.Now(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	// Same reasoning as getAlert's ExternalIssueURL: the Jira base URL and
	// project are server config, not something the browser should carry a
	// second copy of. Empty when Jira is unconfigured.
	now.RemainInJiraURL = s.jira.IssuesSearchURL()
	c.JSON(http.StatusOK, now)
}

// alertStats backs the Dashboards page. It is a BFF endpoint rather than
// public API: the aggregation it returns (and its shape) is free to change
// with the page, not held to the stability the rest of this contract
// promises.
//
//	@Summary	Aggregated stats for the Dashboards page
//	@Id			alertStats
//	@Tags		Alerts
//	@Produce	json
//	@Param		from	query		string	true	"Window start, RFC3339, exclusive"
//	@Param		to		query		string	true	"Window end, RFC3339, inclusive"
//	@Param		team	query		string	false	"Oncall team (the alert's oncall_team label), exact match; omitted or empty matches every team"
//	@Success	200		{object}	statsResponse
//	@Failure	400		{object}	ErrorResponse
//	@Failure	500		{object}	ErrorResponse
//	@Router		/bff/alerts/stats [get]
func (s *Server) alertStats(c *gin.Context) {
	fromStr, toStr := c.Query("from"), c.Query("to")
	if fromStr == "" || toStr == "" {
		c.JSON(http.StatusBadRequest, errorMessage("from and to are both required"))
		return
	}
	from, err := time.Parse(time.RFC3339, fromStr)
	if err != nil {
		c.JSON(http.StatusBadRequest, errorMessage("from is not a valid RFC3339 timestamp: "+fromStr))
		return
	}
	to, err := time.Parse(time.RFC3339, toStr)
	if err != nil {
		c.JSON(http.StatusBadRequest, errorMessage("to is not a valid RFC3339 timestamp: "+toStr))
		return
	}
	if !from.Before(to) {
		c.JSON(http.StatusBadRequest, errorMessage("from must be before to"))
		return
	}

	team := strings.TrimSpace(c.Query("team"))
	stats, err := s.alertStore.Stats(c.Request.Context(), from, to, team)
	if err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	agentStats, err := s.agentRuns.Stats(c.Request.Context(), from, to, team)
	if err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	c.JSON(http.StatusOK, statsResponse{Stats: *stats, Agent: agentStats})
}

// statsResponse is alert.Stats plus the Agent section; composed here because
// pkg/agent already depends on pkg/alert.
type statsResponse struct {
	alert.Stats
	Agent *agent.Stats `json:"agent" validate:"required"`
}

// splitCSV turns a comma-separated query value into its non-empty, trimmed
// members, so the list bar's multi-select controls can pass a set through one
// query parameter. An empty or all-whitespace value yields nil, which the store
// reads as "any".
func splitCSV(v string) []string {
	var out []string
	for _, part := range strings.Split(v, ",") {
		if part = strings.TrimSpace(part); part != "" {
			out = append(out, part)
		}
	}
	return out
}

// @Summary	List alerts
// @Id			listAlerts
// @Tags		Alerts
// @Produce	json
// @Param		classification	query		string	false	"page or ticket; comma-separate to match any of several"
// @Param		state			query		string	false	"open, acked or resolved; comma-separate to match any of several"
// @Param		alertname		query		string	false	"Alert name, exact match"
// @Param		region			query		string	false	"Cloud region"
// @Param		biz_region		query		string	false	"Business region"
// @Param		tenant_kind		query		string	false	"Tenant kind"
// @Param		customer_type	query		string	false	"Customer type"
// @Param		assigned_to		query		string	false	"Assignee email"
// @Param		cluster_id		query		string	false	"Cluster id"
// @Param		tenant_id		query		string	false	"Tenant id"
// @Param		oncall_team		query		string	false	"Oncall team (the alert's oncall_team label), exact match; comma-separate to match any of several"
// @Param		limit			query		int		false	"Page size"		default(50)
// @Param		offset			query		int		false	"Page offset"	default(0)
// @Success	200				{array}		alert.Alert
// @Failure	500				{object}	ErrorResponse
// @Router		/alerts [get]
func (s *Server) listAlerts(c *gin.Context) {
	limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
	offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))

	alerts, err := s.alertStore.List(c.Request.Context(), alert.ListFilter{
		Classifications: splitCSV(c.Query("classification")),
		States:          splitCSV(c.Query("state")),
		Alertname:       c.Query("alertname"),
		Region:          c.Query("region"),
		BizRegion:       c.Query("biz_region"),
		TenantKind:      c.Query("tenant_kind"),
		CustomerType:    c.Query("customer_type"),
		AssignedTo:      c.Query("assigned_to"),
		ClusterID:       c.Query("cluster_id"),
		TenantID:        c.Query("tenant_id"),
		OncallTeams:     splitCSV(c.Query("oncall_team")),
		Limit:           limit,
		Offset:          offset,
	})
	if err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	c.JSON(http.StatusOK, alerts)
}

// @Summary	Get one alert with its instances and labels
// @Id			getAlert
// @Tags		Alerts
// @Produce	json
// @Param		id	path		string	true	"Alert id, ALT-YYMM-NNNN"
// @Success	200	{object}	alert.AlertDetail
// @Failure	404	{object}	ErrorResponse
// @Failure	500	{object}	ErrorResponse
// @Router		/alerts/{id} [get]
func (s *Server) getAlert(c *gin.Context) {
	detail, err := s.alertService.GetDetail(c.Request.Context(), c.Param("id"))
	if err != nil {
		if errors.Is(err, alert.ErrNotFound) {
			c.JSON(http.StatusNotFound, errorMessage("alert not found"))
			return
		}
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	// The browse URL is derived from the server's Jira config rather than shipped
	// to the browser, so the web UI needs no second copy of a setting that has to
	// stay in step with the one the mirror writes with.
	detail.ExternalIssueURL = s.jira.IssueURL(detail.ExternalIssueKey)
	if base := s.opsPortalBaseURL; base != "" && detail.TenantID != "" && detail.ClusterID != "" {
		detail.OpsPortalURL = strings.TrimRight(base, "/") + "/orgs/" + detail.TenantID + "/clusters/" + detail.ClusterID
	}
	c.JSON(http.StatusOK, detail)
}

type ackRequest struct {
	User string `json:"user" binding:"required"`
}

// @Summary	Acknowledge an alert
// @Id			ackAlert
// @Tags		Alerts
// @Accept		json
// @Produce	json
// @Param		id		path		string		true	"Alert id"
// @Param		body	body		ackRequest	true	"Actor"
// @Success	200		{object}	StatusResponse
// @Failure	400		{object}	ErrorResponse
// @Failure	409		{object}	ErrorResponse	"Not a legal transition from the current state"
// @Failure	500		{object}	ErrorResponse
// @Router		/alerts/{id}/ack [post]
func (s *Server) ackAlert(c *gin.Context) {
	var req ackRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, errorBody(err))
		return
	}

	if err := s.alertService.Ack(c.Request.Context(), c.Param("id"), requestActor(c, req.User), "web"); err != nil {
		if errors.Is(err, alert.ErrInvalidTransition) {
			c.JSON(http.StatusConflict, errorBody(err))
			return
		}
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	c.JSON(http.StatusOK, okStatus())
}

type resolveRequest struct {
	User       string `json:"user" binding:"required"`
	Resolution string `json:"resolution" binding:"required"`
	Note       string `json:"note"`
}

// @Summary	Resolve an alert
// @Id			resolveAlert
// @Tags		Alerts
// @Accept		json
// @Produce	json
// @Param		id		path		string			true	"Alert id"
// @Param		body	body		resolveRequest	true	"Resolution and optional note"
// @Success	200		{object}	StatusResponse
// @Failure	400		{object}	ErrorResponse	"Unknown resolution"
// @Failure	409		{object}	ErrorResponse	"Not a legal transition from the current state"
// @Failure	500		{object}	ErrorResponse
// @Router		/alerts/{id}/resolve [post]
func (s *Server) resolveAlert(c *gin.Context) {
	var req resolveRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, errorBody(err))
		return
	}

	if err := s.alertService.Resolve(c.Request.Context(), c.Param("id"), requestActor(c, req.User), "web", req.Resolution, req.Note); err != nil {
		if errors.Is(err, alert.ErrInvalidResolution) {
			c.JSON(http.StatusBadRequest, errorBody(err))
			return
		}
		if errors.Is(err, alert.ErrInvalidTransition) {
			c.JSON(http.StatusConflict, errorBody(err))
			return
		}
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	c.JSON(http.StatusOK, okStatus())
}

type assignRequest struct {
	User     string `json:"user" binding:"required"`
	Assignee string `json:"assignee" binding:"required"`
	Reason   string `json:"reason"`
}

// assignAlert records the new owner, then announces it in the alert's IM
// topic through the outbox — the card is immutable, so without this the
// change is invisible to everyone watching the group.
//
//	@Summary	Assign an alert to someone
//	@Id			assignAlert
//	@Tags		Alerts
//	@Accept		json
//	@Produce	json
//	@Param		id		path		string			true	"Alert id"
//	@Param		body	body		assignRequest	true	"New assignee and reason"
//	@Success	200		{object}	StatusResponse
//	@Failure	400		{object}	ErrorResponse
//	@Failure	500		{object}	ErrorResponse
//	@Router		/alerts/{id}/assign [post]
func (s *Server) assignAlert(c *gin.Context) {
	var req assignRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, errorBody(err))
		return
	}

	ctx := c.Request.Context()
	alertID := c.Param("id")
	actor := requestActor(c, req.User)

	if err := s.alertService.Assign(ctx, alertID, actor, "web", req.Assignee, req.Reason); err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}

	s.announceReassignment(ctx, alertID, actor, req.Assignee, req.Reason)
	c.JSON(http.StatusOK, okStatus())
}

// announceReassignment enqueues the IM announcement for an owner change made
// outside the topic. The assignment is already committed, so a failure here
// only costs the notification.
func (s *Server) announceReassignment(ctx context.Context, alertID, actor, assignee, reason string) {
	a, err := s.alertStore.GetByID(ctx, alertID)
	if err != nil {
		s.logger.Error("load alert for reassignment announcement", "err", err, "alert_id", alertID)
		return
	}
	if err := s.dispatcher.OnReassigned(ctx, a, actor, assignee, reason); err != nil {
		s.logger.Error("enqueue reassignment announcement", "err", err, "alert_id", alertID)
	}
	// Reset escalation: cancel pending calls and restart from step 0 for the
	// new assignee. Generation is time-based so each reassign gets its own
	// dedup namespace.
	generation := int(time.Now().Unix())
	if err := s.dispatcher.ResetEscalation(ctx, a, generation); err != nil {
		s.logger.Error("reset escalation on reassign", "err", err, "alert_id", alertID)
	}
}

type commentRequest struct {
	User string `json:"user" binding:"required"`
	Text string `json:"text" binding:"required"`
}

// commentAlert writes to the timeline (SSOT) first, then mirrors to the IM
// topic via the outbox. Works even while IM is down — the mirror is
// retried, the record is already safe.
//
//	@Summary	Add a comment to an alert's timeline
//	@Id			commentAlert
//	@Tags		Alerts
//	@Accept		json
//	@Produce	json
//	@Param		id		path		string			true	"Alert id"
//	@Param		body	body		commentRequest	true	"Comment text"
//	@Success	200		{object}	StatusResponse
//	@Failure	400		{object}	ErrorResponse
//	@Failure	404		{object}	ErrorResponse
//	@Failure	500		{object}	ErrorResponse
//	@Router		/alerts/{id}/comment [post]
func (s *Server) commentAlert(c *gin.Context) {
	var req commentRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, errorBody(err))
		return
	}

	ctx := c.Request.Context()
	alertID := c.Param("id")

	a, err := s.alertStore.GetByID(ctx, alertID)
	if err != nil {
		if errors.Is(err, alert.ErrNotFound) {
			c.JSON(http.StatusNotFound, errorMessage("alert not found"))
			return
		}
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}

	actor := requestActor(c, req.User)
	entryID, err := s.timelineStore.Insert(ctx, alertID, timeline.KindComment, actor, "web", req.Text, "", nil)
	if err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}

	if err := s.dispatcher.OnComment(ctx, a, strconv.FormatInt(entryID, 10), actor, req.Text); err != nil {
		// The comment is recorded; only the IM mirror enqueue failed.
		s.logger.Error("enqueue comment mirror", "err", err, "alert_id", alertID)
	}
	c.JSON(http.StatusOK, okStatus())
}

// requestActor trusts the identity header set by the devops gateway auth
// plugin. The body value remains as a compatibility fallback for the prior
// private deployment, where requests did not traverse that gateway.
func requestActor(c *gin.Context, fallback string) string {
	if actor := strings.TrimSpace(c.GetHeader("X-User-Email")); actor != "" {
		return actor
	}
	return fallback
}

// @Summary	List an alert's timeline entries
// @Id			alertTimeline
// @Tags		Alerts
// @Produce	json
// @Param		id	path		string	true	"Alert id"
// @Success	200	{array}		timeline.Entry
// @Failure	500	{object}	ErrorResponse
// @Router		/alerts/{id}/timeline [get]
func (s *Server) alertTimeline(c *gin.Context) {
	entries, err := s.timelineStore.ListByAlertID(c.Request.Context(), c.Param("id"))
	if err != nil {
		c.JSON(http.StatusInternalServerError, errorBody(err))
		return
	}
	c.JSON(http.StatusOK, entries)
}
