// Package jira is the transitional write path to Jira, so that automation built
// on jiralert's tickets keeps working while alertstore takes over.
//
// Deliberately unabstracted and self-contained: when the last consumer stops
// reading Jira, this package and the three delivery task kinds that call it are
// deleted in one commit. Only the three verbs migration needs are here — create,
// comment, transition.
package jira

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

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

// ErrPermanent marks a failure retrying cannot fix: a rejected field, a missing
// project, a credential Jira refuses.
var ErrPermanent = errors.New("jira rejected the request")

// Fixed rather than configured: all of it is set once in jiralert's `defaults:`
// and overridden by no receiver, and there is one Jira instance, so there is one
// set of custom field IDs. See docs/design.md.
const (
	issueType = "Task"

	// Custom field IDs, matching jiralert's so downstream readers see no change.
	tenantField   = "customfield_10157"
	clusterField  = "customfield_10149"
	rawAlertField = "customfield_10160"
	// tenantKindField is the one field here nothing of ours reads back:
	// jiraport's assignment rules key on it to tell an alert on an internal
	// account from one a customer is living through, and jiralert has written
	// it since before alertstore existed. Leaving it unset would silently
	// reclassify every mirrored issue as external.
	tenantKindField = "customfield_10159"

	// Transition matches either a transition's own name or the status it leads
	// to, which absorbs inconsistent workflow naming.
	ackTransition     = "In Progress"
	resolveTransition = "Done"

	requestTimeout = 15 * time.Second
)

// Config is what differs between deployments. Leaving base_url, user, token or
// project empty disables the integration, which is the intended end state.
type Config struct {
	BaseURL string `yaml:"base_url"`
	User    string `yaml:"user"`
	Token   string `yaml:"token"`

	// Per deployment, not per alert: jiralert keys its three projects on the
	// receiver only because one jiralert serves every environment. Each
	// alertstore serves one. Holds only while no jiralert forwards across
	// environments — prod's shadow write into dev is off.
	Project string `yaml:"project"`
	// SummaryPrefix is jiralert's per-environment tag ("[PROD]"), which
	// downstream automation matches on.
	SummaryPrefix string `yaml:"summary_prefix"`
}

type Client struct {
	cfg  Config
	http *http.Client
}

func New(cfg Config, httpClient *http.Client) *Client {
	if httpClient == nil {
		httpClient = &http.Client{Timeout: requestTimeout}
	}
	cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/")
	return &Client{cfg: cfg, http: httpClient}
}

// Enabled reports whether alertstore can write to Jira at all. An unconfigured
// deployment says so once at startup rather than failing one task at a time.
func (c *Client) Enabled() bool {
	if c == nil {
		return false
	}
	return c.cfg.BaseURL != "" && c.cfg.User != "" && c.cfg.Token != "" && c.cfg.Project != ""
}

// IssueURL is the browse link for a key, for the card and the web UI.
func (c *Client) IssueURL(key string) string {
	if key == "" || c == nil || c.cfg.BaseURL == "" {
		return ""
	}
	u, err := url.Parse(c.cfg.BaseURL)
	if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
		return ""
	}
	u.Path = path.Join(u.Path, "browse", key)
	return u.String()
}

// IssuesSearchURL is a browse link to every unresolved issue in the
// deployment's project — the Dashboards "Now" strip's "Remain in Jira" tile,
// which counts shadow alerts without a link of its own to point at (there is
// no single issue, just the legacy project). Empty when Jira is unconfigured,
// same as IssueURL, so the caller can tell "nothing to link" from "link
// failed to build". Jira Server/DC's search UI reads its filter off the
// `jql` query parameter directly (`<base>/issues/?jql=...`), so this needs no
// API call.
func (c *Client) IssuesSearchURL() string {
	if c == nil || c.cfg.BaseURL == "" || c.cfg.Project == "" {
		return ""
	}
	u, err := url.Parse(c.cfg.BaseURL)
	if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
		return ""
	}
	u.Path = path.Join(u.Path, "issues") + "/"
	u.RawQuery = url.Values{
		"jql": {fmt.Sprintf("project = %s AND resolution = Unresolved", c.cfg.Project)},
	}.Encode()
	return u.String()
}

func (c *Client) Config() Config { return c.cfg }

// CreateIssue creates the issue and returns its key.
func (c *Client) CreateIssue(ctx context.Context, issue *Issue) (string, error) {
	var out struct {
		Key string `json:"key"`
	}
	if err := c.do(ctx, http.MethodPost, "/rest/api/2/issue", issue.fields(), &out); err != nil {
		return "", err
	}
	if out.Key == "" {
		return "", fmt.Errorf("%w: create returned no issue key", ErrPermanent)
	}
	return out.Key, nil
}

func (c *Client) AddComment(ctx context.Context, issueKey, body string) error {
	payload := map[string]string{"body": body}
	return c.do(ctx, http.MethodPost, "/rest/api/2/issue/"+url.PathEscape(issueKey)+"/comment", payload, nil)
}

type transition struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	To   struct {
		Name           string `json:"name"`
		StatusCategory struct {
			Key string `json:"key"`
		} `json:"statusCategory"`
	} `json:"to"`
}

// TransitionFor names the transition matching an alertstore state, or "" when
// the state has no Jira meaning (an acked alert handed on returns to open, and
// moving the issue backwards would misrepresent it).
func TransitionFor(state string) string {
	switch state {
	case alert.StateAcked:
		return ackTransition
	case alert.StateResolved:
		return resolveTransition
	default:
		return ""
	}
}

// Transition moves an issue by transition name and reports whether it moved.
// Not finding one is a normal answer: Jira only offers the moves legal from the
// current status, so an issue someone already closed has nowhere to go. The
// caller records that and finishes rather than retrying a move never allowed.
func (c *Client) Transition(ctx context.Context, issueKey, name string) (bool, error) {
	if name == "" {
		return false, nil
	}
	endpoint := "/rest/api/2/issue/" + url.PathEscape(issueKey) + "/transitions"

	var list struct {
		Transitions []transition `json:"transitions"`
	}
	if err := c.do(ctx, http.MethodGet, endpoint, nil, &list); err != nil {
		return false, err
	}

	var target *transition
	for i := range list.Transitions {
		t := &list.Transitions[i]
		// Either the transition's name or the status it leads to: workflows name
		// them inconsistently ("Done" vs "Resolve this issue").
		if strings.EqualFold(t.Name, name) || strings.EqualFold(t.To.Name, name) {
			target = t
			break
		}
	}
	if target == nil {
		return false, nil
	}

	body := map[string]interface{}{"transition": map[string]string{"id": target.ID}}
	if err := c.do(ctx, http.MethodPost, endpoint, body, nil); err != nil {
		return false, err
	}
	return true, nil
}

func (c *Client) do(ctx context.Context, method, endpoint string, body, out interface{}) error {
	var reader io.Reader
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("%w: marshal request: %v", ErrPermanent, err)
		}
		reader = bytes.NewReader(encoded)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.cfg.BaseURL+endpoint, reader)
	if err != nil {
		return fmt.Errorf("%w: build request: %v", ErrPermanent, err)
	}
	req.SetBasicAuth(c.cfg.User, c.cfg.Token)
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	resp, err := c.http.Do(req)
	if err != nil {
		// Retryable: the request may not have reached Jira at all.
		return fmt.Errorf("jira %s %s: %w", method, endpoint, err)
	}
	defer resp.Body.Close()

	payload, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
	if resp.StatusCode/100 != 2 {
		err := fmt.Errorf("jira %s %s returned %d: %s", method, endpoint, resp.StatusCode, strings.TrimSpace(string(payload)))
		if retryableStatus(resp.StatusCode) {
			return err
		}
		return fmt.Errorf("%w: %v", ErrPermanent, err)
	}
	if out == nil || len(payload) == 0 {
		return nil
	}
	if err := json.Unmarshal(payload, out); err != nil {
		return fmt.Errorf("%w: decode response: %v", ErrPermanent, err)
	}
	return nil
}

// retryableStatus: rate limiting and server faults pass. A 400, 401 or 404 will
// read the same way in fifteen minutes.
func retryableStatus(status int) bool {
	return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}
