package clinic

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

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

type Config struct {
	BaseURL        string
	Token          string
	ActivityURL    string
	ActivitySource string
	ActivityKey    string
	Timeout        time.Duration
}

type Cluster struct {
	TenantID   string
	Name       string
	Version    string
	DeployType string
}

type clusterResponse struct {
	Items []clusterItem `json:"items"`
}

type clusterItem struct {
	TenantID            string `json:"tenantID"`
	ClusterName         string `json:"clusterName"`
	Version             string `json:"clusterVersion"`
	ClusterDeployTypeV2 string `json:"clusterDeployTypeV2"`
}

type activityResponse struct {
	Result []Activity `json:"result"`
}

type Activity struct {
	DisplayName     string            `json:"display_name"`
	CalibrationTime int64             `json:"calibration_time"`
	Tags            map[string]string `json:"tags"`
}

type Client struct {
	config Config
	http   *http.Client

	clusterCacheMu sync.RWMutex
	clusterCache   map[string]cachedCluster
}

type cachedCluster struct {
	cluster   Cluster
	expiresAt time.Time
}

func New(config Config, httpClient *http.Client) (*Client, error) {
	for name, raw := range map[string]string{
		"base_url":     config.BaseURL,
		"activity_url": config.ActivityURL,
	} {
		if raw == "" {
			continue
		}
		u, err := url.Parse(raw)
		if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
			return nil, fmt.Errorf("invalid clinic %s", name)
		}
	}
	if config.Timeout <= 0 {
		config.Timeout = 5 * time.Second
	}
	if httpClient == nil {
		httpClient = &http.Client{Timeout: config.Timeout}
	}
	return &Client{
		config:       config,
		http:         httpClient,
		clusterCache: make(map[string]cachedCluster),
	}, nil
}

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

func (c *Client) ActivityLookupEnabled() bool {
	return c != nil &&
		c.config.ActivityURL != "" &&
		c.config.ActivitySource != "" &&
		c.config.ActivityKey != ""
}

// GetCluster returns what clinic knows about one cluster. A cluster ID that is
// not a number is not asked about at all — see numericClusterID.
func (c *Client) GetCluster(ctx context.Context, clusterID string) (Cluster, error) {
	if !c.ClusterLookupEnabled() {
		return Cluster{}, ErrDisabled
	}
	clusterID = strings.TrimSpace(clusterID)
	if !numericClusterID(clusterID) {
		return Cluster{}, nil
	}
	if cluster, ok := c.cachedCluster(clusterID); ok {
		return cluster, nil
	}

	u, _ := url.Parse(c.config.BaseURL)
	u.Path = path.Join(u.Path, "/clinic/api/v1/dashboard/clusters2")
	query := u.Query()
	query.Set("cluster_id", clusterID)
	u.RawQuery = query.Encode()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return Cluster{}, fmt.Errorf("build clinic cluster request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+c.config.Token)

	resp, err := c.http.Do(req)
	if err != nil {
		return Cluster{}, fmt.Errorf("get cluster from clinic: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		return Cluster{}, responseError("clinic cluster lookup", resp)
	}

	var payload clusterResponse
	if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&payload); err != nil {
		return Cluster{}, fmt.Errorf("decode clinic cluster response: %w", err)
	}
	if len(payload.Items) == 0 {
		c.storeCluster(clusterID, Cluster{})
		return Cluster{}, nil
	}
	item := payload.Items[0]
	cluster := Cluster{
		TenantID:   item.TenantID,
		Name:       item.ClusterName,
		Version:    item.Version,
		DeployType: normalizeDeployType(item.ClusterDeployTypeV2),
	}
	c.storeCluster(clusterID, cluster)
	return cluster, nil
}

func (c *Client) cachedCluster(clusterID string) (Cluster, bool) {
	c.clusterCacheMu.RLock()
	entry, ok := c.clusterCache[clusterID]
	c.clusterCacheMu.RUnlock()
	if !ok || time.Now().After(entry.expiresAt) {
		return Cluster{}, false
	}
	return entry.cluster, true
}

func (c *Client) storeCluster(clusterID string, cluster Cluster) {
	c.clusterCacheMu.Lock()
	c.clusterCache[clusterID] = cachedCluster{
		cluster:   cluster,
		expiresAt: time.Now().Add(10 * time.Minute),
	}
	c.clusterCacheMu.Unlock()
}

func (c *Client) PortalURL(clusterID string) string {
	if c == nil || strings.TrimSpace(clusterID) == "" {
		return ""
	}
	if c.config.BaseURL == "" {
		return ""
	}
	u, err := url.Parse(c.config.BaseURL)
	if err != nil {
		return ""
	}
	u.Path = path.Join(u.Path, "portal") + "/"
	u.Fragment = "/clusters?query=" + url.QueryEscape(clusterID)
	return u.String()
}

// HasRelatedActivity preserves jiralert's EVENT prechecks. It deliberately
// checks only alert names and ActivityHub event names configured in the old
// system; unrelated activity must not downgrade a page.
func (c *Client) HasRelatedActivity(
	ctx context.Context,
	alertname string,
	clusterID string,
	observedAt time.Time,
) (bool, error) {
	eventNames, ok := relatedActivityRules[alertname]
	if !ok {
		return false, nil
	}
	if !c.ActivityLookupEnabled() {
		return false, ErrDisabled
	}
	clusterID = strings.TrimSpace(clusterID)
	if !numericClusterID(clusterID) {
		return false, nil
	}
	if observedAt.IsZero() {
		observedAt = time.Now()
	}

	short, err := c.getActivities(ctx, clusterID, observedAt.Add(-time.Hour), observedAt)
	if err != nil {
		return false, err
	}
	if firstMatchingActivity(short, eventNames) != nil {
		return true, nil
	}

	long, err := c.getActivities(ctx, clusterID, observedAt.Add(-24*time.Hour), observedAt.Add(-30*time.Minute))
	if err != nil {
		return false, err
	}
	return hasUnclosedMatchingActivity(long, eventNames), nil
}

func (c *Client) getActivities(
	ctx context.Context,
	clusterID string,
	from time.Time,
	to time.Time,
) ([]Activity, error) {
	u, _ := url.Parse(c.config.ActivityURL)
	u.Path = path.Join(
		u.Path,
		url.PathEscape(clusterID),
		"from",
		strconv.FormatInt(from.Unix(), 10),
		"to",
		strconv.FormatInt(to.Unix(), 10),
	)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return nil, fmt.Errorf("build clinic activity request: %w", err)
	}
	req.Header.Set("x-activityhub-source", c.config.ActivitySource)
	req.Header.Set("x-activityhub-key", c.config.ActivityKey)

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("get clinic activities: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		return nil, responseError("clinic activity lookup", resp)
	}

	var payload activityResponse
	if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&payload); err != nil {
		return nil, fmt.Errorf("decode clinic activity response: %w", err)
	}
	return payload.Result, nil
}

// numericClusterID reports whether clinic could answer about this ID at all.
// TiDB Cloud numbers its clusters, and both endpoints here are keyed by that
// number: the dashboard lookup answers with an empty item list for anything
// else, and ActivityHub puts the ID straight in the path.
//
// Alerts nonetheless arrive carrying names in the cluster labels — the fleet's
// own infrastructure is monitored through the same Alertmanager, and
// alert.Fact prefers a numeric label but falls back to whatever is set
// (`namespace` among them), so a name reaches this client.
//
// Asking anyway would be one useless request per occurrence, forever: an empty
// answer is cached but a failed one is not, and the activity precheck runs on
// every page. pkg/devops guards its tenant IDs the same way, for the same
// reason.
func numericClusterID(clusterID string) bool {
	if clusterID == "" {
		return false
	}
	for _, r := range clusterID {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

func responseError(operation string, resp *http.Response) error {
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
	message := strings.TrimSpace(string(body))
	if message == "" {
		message = resp.Status
	}
	return fmt.Errorf("%s failed: HTTP %d: %s", operation, resp.StatusCode, message)
}

func normalizeDeployType(raw string) string {
	word := strings.TrimSpace(strings.SplitN(raw, " ", 2)[0])
	if word == "" {
		return ""
	}
	return strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}

func firstMatchingActivity(activities []Activity, eventNames map[string]struct{}) *Activity {
	for i := range activities {
		if activityNameMatches(activities[i].DisplayName, eventNames) {
			return &activities[i]
		}
	}
	return nil
}

func hasUnclosedMatchingActivity(activities []Activity, eventNames map[string]struct{}) bool {
	sort.SliceStable(activities, func(i, j int) bool {
		return activities[i].CalibrationTime < activities[j].CalibrationTime
	})
	unclosed := make(map[string]struct{})
	for _, activity := range activities {
		if !activityNameMatches(activity.DisplayName, eventNames) {
			continue
		}
		if strings.Contains(activity.DisplayName, "configchange") {
			if activity.DisplayName == "configchange:start" {
				unclosed["configchange"] = struct{}{}
			} else if activity.DisplayName == "configchange:finish" {
				delete(unclosed, "configchange")
			}
			continue
		}
		switch activity.Tags["status"] {
		case "started":
			unclosed[activity.DisplayName] = struct{}{}
		case "succeeded":
			delete(unclosed, activity.DisplayName)
		}
	}
	return len(unclosed) > 0
}

func activityNameMatches(name string, eventNames map[string]struct{}) bool {
	if _, ok := eventNames[name]; ok {
		return true
	}
	if strings.Contains(name, "configchange") {
		for eventName := range eventNames {
			if strings.Contains(eventName, "configchange") {
				return true
			}
		}
	}
	return false
}

func activityNames(names ...string) map[string]struct{} {
	result := make(map[string]struct{}, len(names))
	for _, name := range names {
		result[name] = struct{}{}
	}
	return result
}

var frequentRestartActivities = activityNames(
	"PauseCluster",
	"configchange:start",
	"ModifyClusterSize",
	"CreateCluster",
	"ResumeCluster",
	"MaintenanceTask",
	"ApplyClusterConfig",
)

var relatedActivityRules = map[string]map[string]struct{}{
	"TiProxyClusterFrequentlyRestart": frequentRestartActivities,
	"PDClusterFrequentlyRestart":      frequentRestartActivities,
	"TiKVClusterFrequentlyRestart":    frequentRestartActivities,
	"TiDBClusterFrequentlyRestart":    frequentRestartActivities,
	"TiFlashClusterFrequentlyRestart": frequentRestartActivities,
	"TiDBServerFrequentlyRestart":     frequentRestartActivities,
	"TiKVServerFrequentlyRestart":     frequentRestartActivities,
	"PDServerFrequentlyRestart":       frequentRestartActivities,
	"TiFlashServerFrequentlyRestart":  frequentRestartActivities,
	"PDDiscoverDownStore": activityNames(
		"PauseCluster",
		"CreateCluster",
		"ResumeCluster",
	),
}
