// Package devops is the client for the DevOps ops portal's tenant API, which
// owns the commercial view of a tenant: which sales region it belongs to and
// whether it is one of ours or a customer's.
//
// Neither answer is in an alert's labels and neither is in Clinic. Alertmanager
// labels carry the cloud region (`ap-northeast-1`) — where the cluster runs,
// not who owns it — and Clinic knows the cluster, not the account behind it.
// jiralert and jiraport reached into `devops_prod.tenants` with raw SQL for
// exactly these two columns (`kind`, and `sales_force_region` falling back to
// `country_region`); alertstore asks the service that owns the table instead,
// which is also where the Salesforce overrides on both fields are already
// applied.
//
// The call goes to the portal's internal API (`/api/v1/devops-internal`), which
// carries no auth middleware and is reachable on the private network only —
// alertstore runs in the same cluster, the same arrangement as pkg/oncall and
// pkg/directory. services/jsm-syncer reads tenants over the same route.
//
// Everything here is fail-open, like pkg/clinic and pkg/oncall: an alert that
// cannot be enriched is still an alert, and blocking ingestion on the ops
// portal would trade a missing header row for a missing page.
package devops

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

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

const (
	defaultTimeout = 5 * time.Second
	// A tenant's region and kind change when a contract does — months apart,
	// and never in a way an alert has to see immediately. The TTL is here to
	// keep an alert storm on one tenant from becoming a request storm on the
	// ops portal.
	defaultCacheTTL = 30 * time.Minute
	// maxCacheEntries caps what a long-running process accumulates: one entry
	// per tenant that has ever fired an alert. Past the cap the cache is
	// dropped whole rather than evicted one entry at a time, which costs one
	// request per tenant still firing and keeps the bookkeeping to a counter.
	maxCacheEntries = 4096
)

// Config addresses the ops portal's internal listener, which authenticates
// nothing because it is reachable only from inside the cluster. An empty
// BaseURL disables lookup — the two fields alertstore reads are then simply
// absent, as they were before this client existed.
type Config struct {
	BaseURL string
	// APIToken is optional and empty in-cluster. Set it only to reach the
	// authenticated listener (`/api/v1/devops`) through the shared gateway
	// instead, the way ticket-manager and jsm-syncer do from outside.
	APIToken string
	Timeout  time.Duration
	// CacheTTL is how long one tenant's answer is reused.
	CacheTTL time.Duration
}

// Tenant is the commercial profile of an account, trimmed to what an alert
// header shows.
type Tenant struct {
	// BizRegion is the sales region — NA, EMEA, APAC, JAPAN, LATAM, RDG — not
	// the cloud region the cluster runs in. DevOps resolves it from the
	// Salesforce record, falling back to the account's country.
	BizRegion string
	// Kind is `internal` or `external`: ours or a customer's. It is what tells
	// an alert on a test account from one a customer is living through, and
	// jiraport's assignment rules have keyed on it since jiralert wrote it into
	// the Jira issue.
	Kind string
	// Segmentation is the sales tier — SKA, KA, SMB, UNKNOWN — synced from
	// Salesforce. Alertstore stores it as `customer_type`, the name its own
	// filters have used since the service was designed; see alert.AlertAttrs.
	Segmentation string
	// Name is the account's display name, kept because the same response
	// carries it and a numeric tenant ID tells a reader nothing.
	Name string
}

type Client struct {
	config Config
	http   *http.Client

	cacheMu sync.RWMutex
	cache   map[string]cachedTenant
}

type cachedTenant struct {
	tenant    Tenant
	expiresAt time.Time
}

// tenantResponse mirrors the DevOps `GET /api/v1/devops-internal/tenants/{id}`
// payload (devops `api/tenant.TenantResponse`), redeclared here because
// alertstore is built as a standalone module. The internal and authenticated
// routes render the same struct through the same converter, so pointing this
// client at either changes nothing it reads. Only the fields alertstore uses
// are listed; the response carries the whole commercial record, including
// contact details that have no business in an alert.
//
// `region` and `kind` arrive already resolved: DevOps prefers the Salesforce
// values and falls back to the account's own. Alertstore takes them as given
// rather than re-deriving the precedence, which is how jiralert and jiraport
// came to disagree about the same tenant.
type tenantResponse struct {
	Name         string `json:"name"`
	Region       string `json:"region"`
	Kind         string `json:"kind"`
	Segmentation string `json:"segmentation"`
}

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 devops 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]cachedTenant),
	}, nil
}

// Enabled reports whether tenant lookup is configured. Only the address: the
// internal route needs no credential, so requiring one would disable enrichment
// in exactly the deployment it is meant for.
func (c *Client) Enabled() bool {
	return c != nil && c.config.BaseURL != ""
}

// GetTenant returns one account's commercial profile. An unknown tenant is not
// an error: DevOps answers with an empty record, and alertstore caches that
// too, so a cluster whose tenant has been deleted costs one request rather than
// one per occurrence.
//
// A tenant ID that is not a number is not asked about at all — see
// numericTenantID.
func (c *Client) GetTenant(ctx context.Context, tenantID string) (Tenant, error) {
	if !c.Enabled() {
		return Tenant{}, ErrDisabled
	}
	tenantID = strings.TrimSpace(tenantID)
	if !numericTenantID(tenantID) {
		return Tenant{}, nil
	}
	if tenant, ok := c.cachedTenant(tenantID); ok {
		return tenant, nil
	}

	u, err := url.Parse(c.config.BaseURL)
	if err != nil {
		return Tenant{}, fmt.Errorf("parse devops base url: %w", err)
	}
	u.Path = path.Join(u.Path, "/api/v1/devops-internal/tenants", url.PathEscape(tenantID))

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return Tenant{}, fmt.Errorf("build devops tenant request: %w", err)
	}
	req.Header.Set("Accept", "application/json")
	// Only when configured, and then in both spellings jsm-syncer uses: the
	// internal route reads neither, and the gateway in front of the
	// authenticated one reads whichever that deployment was set up with.
	if c.config.APIToken != "" {
		req.Header.Set("X-API-TOKEN", c.config.APIToken)
		req.Header.Set("X-API-KEY", c.config.APIToken)
	}

	resp, err := c.http.Do(req)
	if err != nil {
		return Tenant{}, fmt.Errorf("get tenant from devops: %w", 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
		}
		return Tenant{}, fmt.Errorf("devops tenant lookup failed: HTTP %d: %s", resp.StatusCode, message)
	}

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

	tenant := Tenant{
		BizRegion: strings.TrimSpace(payload.Region),
		Kind:      strings.ToLower(strings.TrimSpace(payload.Kind)),
		// Region and segmentation keep the portal's own casing (NA, KA); only
		// kind is folded down, because alertstore had already spelled its two
		// values in lowercase before this client existed.
		Segmentation: strings.TrimSpace(payload.Segmentation),
		Name:         strings.TrimSpace(payload.Name),
	}
	c.storeTenant(tenantID, tenant)
	return tenant, nil
}

// numericTenantID reports whether the ops portal could answer about this ID at
// all. Its tenants are numbered, and it rejects anything else outright:
//
//	HTTP 400 {"errcode":"INVALID_ARGUMENT","errmsg":"id is not a int"}
//
// Alerts nonetheless arrive carrying names in the tenant labels — `infra-dev`,
// `staging-devtier` — because those labels are also used for the fleet's own
// infrastructure, which has no account behind it. alert.Fact prefers a numeric
// label but falls back to whatever is set, so such a name reaches this client.
//
// Asking anyway would be one doomed request per occurrence, forever: the answer
// is a failure, and failures are not cached. jiralert and jiraport parse the ID
// before querying for the same reason.
func numericTenantID(tenantID string) bool {
	if tenantID == "" {
		return false
	}
	for _, r := range tenantID {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

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

func (c *Client) storeTenant(tenantID string, tenant Tenant) {
	c.cacheMu.Lock()
	defer c.cacheMu.Unlock()
	if len(c.cache) >= maxCacheEntries {
		c.cache = make(map[string]cachedTenant, maxCacheEntries)
	}
	c.cache[tenantID] = cachedTenant{
		tenant:    tenant,
		expiresAt: time.Now().Add(c.config.CacheTTL),
	}
}
