package main

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"fn-registry/functions/infra"
)

// jiraImportRequest is the body shape for POST /api/jira/import.
type jiraImportRequest struct {
	IssueKeys        []string `json:"issue_keys"`
	FallbackColumnID string   `json:"fallback_column_id"` // optional: where to land issues whose status has no kanban mapping
}

// jiraIssueOut is what we return in GET /api/jira/issues for the frontend
// import picker. We deliberately keep this small — clicking a row redirects
// to Jira for full detail.
type jiraIssueOut struct {
	Key             string `json:"key"`
	Summary         string `json:"summary"`
	StatusName      string `json:"status_name"`
	IssueType       string `json:"issue_type"`
	Assignee        string `json:"assignee"`
	Updated         string `json:"updated"`
	URL             string `json:"url"`
	AlreadyImported bool   `json:"already_imported"`
	MappedColumnID  string `json:"mapped_column_id,omitempty"`
	IssueTypeIcon   string `json:"issue_type_icon,omitempty"`
}

// activeJiraModule returns the first enabled Jira module + its decoded config,
// or an error if no module is configured. The handlers below need both the
// credentials and the status_map to operate.
func activeJiraModule(db *DB) (Module, jiraConfig, error) {
	mods, err := db.listModulesEnabled()
	if err != nil {
		return Module{}, jiraConfig{}, err
	}
	for _, m := range mods {
		if m.Kind != "jira" {
			continue
		}
		cfg, perr := parseJiraConfig(m)
		if perr != nil {
			return Module{}, jiraConfig{}, perr
		}
		return m, cfg, nil
	}
	return Module{}, jiraConfig{}, fmt.Errorf("no enabled jira module configured")
}

// jiraGET performs an authenticated GET against the configured Jira API and
// decodes the JSON response into out.
func jiraGET(ctx context.Context, cfg jiraConfig, path string, out interface{}) (int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.BaseURL+path, nil)
	if err != nil {
		return 0, err
	}
	req.Header.Set("Accept", "application/json")
	if cfg.Email != "" && cfg.APIToken != "" {
		basic := base64.StdEncoding.EncodeToString([]byte(cfg.Email + ":" + cfg.APIToken))
		req.Header.Set("Authorization", "Basic "+basic)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
	if resp.StatusCode >= 400 {
		return resp.StatusCode, fmt.Errorf("jira GET %s: %d %s", path, resp.StatusCode, truncate(body, 240))
	}
	if out != nil {
		if err := json.Unmarshal(body, out); err != nil {
			return resp.StatusCode, fmt.Errorf("decode %s: %w", path, err)
		}
	}
	return resp.StatusCode, nil
}

// extractADFText walks an Atlassian Document Format JSON node and collects the
// text content. Returns "" when the input is not a valid ADF doc. Used to
// pre-populate the kanban card description on import — operators can edit it
// later, the Jira link is the source of truth for rich content.
func extractADFText(raw json.RawMessage) string {
	if len(raw) == 0 || string(raw) == "null" {
		return ""
	}
	var node struct {
		Type    string            `json:"type"`
		Text    string            `json:"text"`
		Content []json.RawMessage `json:"content"`
	}
	if err := json.Unmarshal(raw, &node); err != nil {
		return ""
	}
	var buf bytes.Buffer
	collectADFText(&buf, node.Type, node.Text, node.Content)
	return strings.TrimSpace(buf.String())
}

func collectADFText(buf *bytes.Buffer, nodeType, text string, content []json.RawMessage) {
	if text != "" {
		buf.WriteString(text)
	}
	for _, c := range content {
		var inner struct {
			Type    string            `json:"type"`
			Text    string            `json:"text"`
			Content []json.RawMessage `json:"content"`
		}
		if err := json.Unmarshal(c, &inner); err != nil {
			continue
		}
		collectADFText(buf, inner.Type, inner.Text, inner.Content)
	}
	// Paragraph / list-item / heading boundaries get a newline so the result
	// is roughly readable in the kanban card body.
	switch nodeType {
	case "paragraph", "heading", "listItem", "bulletList", "orderedList":
		buf.WriteString("\n")
	}
}

// reverseStatusMap inverts the kanban-col-name -> jira-status-name mapping so
// importers can land an issue in the column whose status matches. Lower-cased
// keys for case-insensitive lookup.
func reverseStatusMap(cfg jiraConfig) map[string]string {
	out := make(map[string]string, len(cfg.StatusMap))
	for col, status := range cfg.StatusMap {
		out[strings.ToLower(status)] = col
	}
	return out
}

// handleListJiraIssues fetches up to `limit` issues from the configured Jira
// board and annotates each with whether it is already imported into kanban
// and (if mappable) the kanban column it would land in.
func handleListJiraIssues(db *DB) http.HandlerFunc {
	return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
		_, cfg, err := activeJiraModule(db)
		if err != nil {
			badRequest(w, err.Error())
			return
		}
		if cfg.BoardID <= 0 {
			badRequest(w, "module is missing board_id")
			return
		}
		limit := 100
		if v := r.URL.Query().Get("limit"); v != "" {
			if n, perr := strconv.Atoi(v); perr == nil && n > 0 && n <= 200 {
				limit = n
			}
		}
		showImported := r.URL.Query().Get("include_imported") == "true"

		q := url.Values{}
		q.Set("maxResults", strconv.Itoa(limit))
		q.Set("fields", "summary,status,assignee,updated,issuetype,description")
		path := fmt.Sprintf("/rest/agile/1.0/board/%d/issue?%s", cfg.BoardID, q.Encode())

		var page struct {
			Issues []struct {
				Key    string `json:"key"`
				Fields struct {
					Summary string `json:"summary"`
					Status  struct {
						Name string `json:"name"`
					} `json:"status"`
					Assignee *struct {
						DisplayName string `json:"displayName"`
					} `json:"assignee"`
					Updated   string `json:"updated"`
					IssueType struct {
						Name    string `json:"name"`
						IconURL string `json:"iconUrl"`
					} `json:"issuetype"`
				} `json:"fields"`
			} `json:"issues"`
		}
		ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout)
		defer cancel()
		if _, err := jiraGET(ctx, cfg, path, &page); err != nil {
			serverError(w, err)
			return
		}

		// Build lookup: jira_key -> bool (already imported)
		importedKeys, err := db.listImportedJiraKeys()
		if err != nil {
			serverError(w, err)
			return
		}
		colByName, err := db.listColumnsByName()
		if err != nil {
			serverError(w, err)
			return
		}
		statusToCol := reverseStatusMap(cfg)

		out := make([]jiraIssueOut, 0, len(page.Issues))
		for _, iss := range page.Issues {
			already := importedKeys[iss.Key]
			if already && !showImported {
				continue
			}
			row := jiraIssueOut{
				Key:             iss.Key,
				Summary:         iss.Fields.Summary,
				StatusName:      iss.Fields.Status.Name,
				IssueType:       iss.Fields.IssueType.Name,
				Updated:         iss.Fields.Updated,
				URL:             cfg.BaseURL + "/browse/" + iss.Key,
				AlreadyImported: already,
				IssueTypeIcon:   iss.Fields.IssueType.IconURL,
			}
			if iss.Fields.Assignee != nil {
				row.Assignee = iss.Fields.Assignee.DisplayName
			}
			if colName, ok := statusToCol[strings.ToLower(iss.Fields.Status.Name)]; ok {
				if col, ok := colByName[colName]; ok {
					row.MappedColumnID = col.ID
				}
			}
			out = append(out, row)
		}
		infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
			"issues":            out,
			"board_id":          cfg.BoardID,
			"project_key":       cfg.ProjectKey,
			"status_to_column":  statusToCol,
			"include_imported":  showImported,
		})
	})
}

// handleImportJiraIssues creates a kanban card for each requested issue_key
// and links it to the existing Jira issue (sets jira_key directly, so the
// dispatcher will treat any future kanban edits as updates instead of trying
// to create a duplicate). The card lands in the column whose status_map entry
// matches the issue's current status; falls back to FallbackColumnID when
// unmappable.
func handleImportJiraIssues(db *DB) http.HandlerFunc {
	return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
		uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
		var body jiraImportRequest
		if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
			badRequest(w, err.Error())
			return
		}
		if len(body.IssueKeys) == 0 {
			badRequest(w, "issue_keys required")
			return
		}
		_, cfg, err := activeJiraModule(db)
		if err != nil {
			badRequest(w, err.Error())
			return
		}
		colByName, err := db.listColumnsByName()
		if err != nil {
			serverError(w, err)
			return
		}
		statusToCol := reverseStatusMap(cfg)

		results := make([]map[string]interface{}, 0, len(body.IssueKeys))
		ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.IssueKeys)+1))
		defer cancel()

		for _, key := range body.IssueKeys {
			res := map[string]interface{}{"key": key}
			// Skip if already imported.
			if existing, _ := db.findCardByJiraKey(key); existing != "" {
				res["status"] = "skipped"
				res["error"] = "already imported (card " + existing + ")"
				results = append(results, res)
				continue
			}
			// Fetch issue detail to get summary + description + status.
			var iss struct {
				Fields struct {
					Summary string `json:"summary"`
					Status  struct {
						Name string `json:"name"`
					} `json:"status"`
					Description json.RawMessage `json:"description"`
					Assignee    *struct {
						DisplayName string `json:"displayName"`
					} `json:"assignee"`
				} `json:"fields"`
			}
			if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(key), &iss); err != nil {
				res["status"] = "error"
				res["error"] = err.Error()
				results = append(results, res)
				continue
			}
			// Determine target column.
			columnID := body.FallbackColumnID
			if colName, ok := statusToCol[strings.ToLower(iss.Fields.Status.Name)]; ok {
				if col, ok := colByName[colName]; ok {
					columnID = col.ID
				}
			}
			if columnID == "" {
				res["status"] = "error"
				res["error"] = fmt.Sprintf("no column mapping for status %q and no fallback_column_id", iss.Fields.Status.Name)
				results = append(results, res)
				continue
			}
			requester := ""
			if iss.Fields.Assignee != nil {
				requester = iss.Fields.Assignee.DisplayName
			}
			description := extractADFText(iss.Fields.Description)
			if description == "" {
				description = "Imported from Jira " + key
			} else {
				description = description + "\n\n— Imported from Jira " + key
			}
			card, cerr := db.CreateCard(columnID, requester, iss.Fields.Summary, description, uid)
			if cerr != nil {
				res["status"] = "error"
				res["error"] = cerr.Error()
				results = append(results, res)
				continue
			}
			// Link to existing Jira issue + seed sync state so the indicator
			// renders green immediately.
			if err := db.setCardJiraKey(card.ID, key); err != nil {
				res["status"] = "error"
				res["error"] = "card created but link failed: " + err.Error()
				results = append(results, res)
				continue
			}
			now := time.Now().UTC().Format(time.RFC3339)
			_ = db.updateCardJiraSync(card.ID, iss.Fields.Status.Name, now, "")
			res["status"] = "imported"
			res["card_id"] = card.ID
			res["column_id"] = columnID
			results = append(results, res)
		}

		infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
			"results": results,
		})
	})
}
