// Package directory is the client for core-server's user directory
// (aisupport `services/core-server`), which mirrors the company's Lark org
// chart. Alertstore uses it for one thing: turning the few characters an
// operator types into the person they meant, so reassigning an alert is a pick
// from a list instead of an email address typed from memory.
//
// The directory is deliberately not the on-call roster (pkg/oncall). Whoever
// picks an alert up next is often not on duty for it — an escalation, a handoff
// to the person who wrote the code — so the candidate list is everyone, not the
// lineup.
//
// Everything here is fail-open, for the same reason as pkg/oncall: a directory
// outage must cost suggestions and nothing else. The assignee field stays free
// text, so an operator who knows the address can always type it.
package directory

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

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

const (
	defaultTimeout = 5 * time.Second
	// The directory changes when someone joins or leaves the company, so a
	// generous TTL is safe. It exists to keep a burst of keystrokes off
	// core-server, not to keep the answer fresh.
	defaultCacheTTL = 5 * time.Minute
	// maxCacheEntries caps what one search box can accumulate: every distinct
	// prefix anyone types is its own key. Past the cap the cache is dropped
	// whole rather than evicted one entry at a time, because refilling it costs
	// one request per prefix that is still being typed.
	maxCacheEntries = 512
)

// Config addresses core-server's in-cluster listener, which authenticates
// nothing — the shared devops gateway does that in front of it, and alertstore
// runs behind that gateway in the same cluster. There is no credential here
// because the call never leaves it.
type Config struct {
	BaseURL string
	Timeout time.Duration
	// CacheTTL is how long one keyword's answer is reused.
	CacheTTL time.Duration
}

// User is one directory entry, trimmed to what a picker shows. Email is the
// identity alertstore stores; the rest is there so a human recognises the row.
type User struct {
	Email      string `json:"email" validate:"required"`
	Name       string `json:"name" validate:"required"`
	EnName     string `json:"en_name" validate:"required"`
	Department string `json:"department" validate:"required"`
}

type Client struct {
	config Config
	http   *http.Client

	cacheMu sync.RWMutex
	cache   map[string]cachedResult
}

type cachedResult struct {
	users     []User
	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 directory 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]cachedResult),
	}, nil
}

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

// Search returns at most limit directory entries matching keyword. core-server
// matches it against email, Chinese name, English name, nickname and the pinyin
// of the name, so one search box serves "wang", "王" and "wk@" alike.
func (c *Client) Search(ctx context.Context, keyword string, limit int) ([]User, error) {
	if !c.Enabled() {
		return nil, ErrDisabled
	}
	keyword = strings.TrimSpace(keyword)
	if keyword == "" {
		return nil, errors.New("empty search keyword")
	}
	if limit <= 0 {
		limit = 10
	}

	cacheKey := strings.ToLower(keyword) + "\x00" + strconv.Itoa(limit)
	if users, ok := c.cachedResult(cacheKey); ok {
		return users, nil
	}

	u, err := url.Parse(c.config.BaseURL)
	if err != nil {
		return nil, fmt.Errorf("parse directory base url: %w", err)
	}
	u.Path = path.Join(u.Path, "/api/v1/core/user/users")
	// core-server's HTTP surface is a grpc-gateway, so its query parameters and
	// its JSON fields are the protobuf names in camelCase.
	query := u.Query()
	query.Set("userKeyword", keyword)
	query.Set("page", "1")
	query.Set("perPage", strconv.Itoa(limit))
	u.RawQuery = query.Encode()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return nil, fmt.Errorf("build user search request: %w", err)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("search user directory: %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 nil, fmt.Errorf("user directory search failed: HTTP %d: %s", resp.StatusCode, message)
	}

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

	users := make([]User, 0, len(payload.Users))
	for _, item := range payload.Users {
		email := strings.TrimSpace(item.Email)
		if email == "" {
			continue
		}
		users = append(users, User{
			Email:      email,
			Name:       strings.TrimSpace(item.LarkUserName),
			EnName:     strings.TrimSpace(item.LarkUserEnName),
			Department: strings.TrimSpace(item.LarkDepartmentName),
		})
	}
	c.storeResult(cacheKey, users)
	return users, nil
}

// listUsersResponse mirrors core-server's UserService.ListUsers response
// (packages/contracts/core-server/api/core-server/user/v1). It is redeclared
// here, like the oncall contract, because alertstore is built as a standalone
// module. Users the directory marks unavailable are already excluded by
// core-server unless includeUnavailable is asked for.
type listUsersResponse struct {
	Users []struct {
		Email              string `json:"email"`
		LarkUserName       string `json:"larkUserName"`
		LarkUserEnName     string `json:"larkUserEnName"`
		LarkDepartmentName string `json:"larkDepartmentName"`
	} `json:"users"`
}

func (c *Client) cachedResult(key string) ([]User, bool) {
	c.cacheMu.RLock()
	entry, ok := c.cache[key]
	c.cacheMu.RUnlock()
	if !ok || time.Now().After(entry.expiresAt) {
		return nil, false
	}
	return entry.users, true
}

func (c *Client) storeResult(key string, users []User) {
	c.cacheMu.Lock()
	defer c.cacheMu.Unlock()
	if len(c.cache) >= maxCacheEntries {
		c.cache = make(map[string]cachedResult, maxCacheEntries)
	}
	c.cache[key] = cachedResult{
		users:     users,
		expiresAt: time.Now().Add(c.config.CacheTTL),
	}
}
