package vcs

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

	"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)

// DefaultGiteaBaseURL is the platform's self-hosted Gitea instance.
const DefaultGiteaBaseURL = "https://git.jerryyan.net"

// giteaErrBodyLimit caps how much of a non-2xx response body is surfaced in the
// wrapped error message.
const giteaErrBodyLimit = 512

// GiteaProvider implements Provider against the Gitea REST v1 API. Auth uses
// the "Authorization: token <PAT>" header with a single platform-level token.
type GiteaProvider struct {
	baseURL string
	token   string
	client  *http.Client
}

// NewGiteaProvider constructs a GiteaProvider. baseURL empty defaults to
// DefaultGiteaBaseURL; timeout <= 0 defaults to 15s. A fresh http.Client is
// used (low call frequency), mirroring the webhook handler pattern.
func NewGiteaProvider(baseURL, token string, timeout time.Duration) *GiteaProvider {
	if baseURL == "" {
		baseURL = DefaultGiteaBaseURL
	}
	if timeout <= 0 {
		timeout = 15 * time.Second
	}
	return &GiteaProvider{
		baseURL: strings.TrimRight(baseURL, "/"),
		token:   token,
		client:  &http.Client{Timeout: timeout},
	}
}

// Host returns the bare host (no scheme/port) this provider serves, for
// ParseRepoRef host matching.
func (g *GiteaProvider) Host() string {
	u, err := url.Parse(g.baseURL)
	if err != nil || u.Host == "" {
		return ""
	}
	return u.Hostname()
}

// ===== Gitea wire DTOs =====

type giteaPR struct {
	Number         int64       `json:"number"`
	State          string      `json:"state"`
	Mergeable      bool        `json:"mergeable"`
	Merged         bool        `json:"merged"`
	MergedCommitID string      `json:"merge_commit_sha"`
	HTMLURL        string      `json:"html_url"`
	Head           giteaBranch `json:"head"`
}

type giteaBranch struct {
	Sha string `json:"sha"`
	Ref string `json:"ref"`
}

type giteaCombinedStatus struct {
	State string `json:"state"` // pending|success|error|failure
}

type giteaIssue struct {
	Number  int64  `json:"number"`
	Title   string `json:"title"`
	State   string `json:"state"`
	HTMLURL string `json:"html_url"`
}

// ===== Provider impl =====

func (g *GiteaProvider) CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error) {
	body := map[string]any{
		"head":  in.Head,
		"base":  in.Base,
		"title": in.Title,
		"body":  in.Body,
	}
	path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls", esc(in.Repo.Owner), esc(in.Repo.Name))
	var pr giteaPR
	if err := g.do(ctx, http.MethodPost, path, body, &pr); err != nil {
		return nil, err
	}
	out := mapPR(pr)
	// best-effort CI state from the head commit's combined status
	out.CIState = g.ciState(ctx, in.Repo, pr.Head.Sha)
	return &out, nil
}

func (g *GiteaProvider) GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error) {
	path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", esc(repo.Owner), esc(repo.Name), number)
	var pr giteaPR
	if err := g.do(ctx, http.MethodGet, path, nil, &pr); err != nil {
		return nil, err
	}
	out := mapPR(pr)
	out.CIState = g.ciState(ctx, repo, pr.Head.Sha)
	return &out, nil
}

func (g *GiteaProvider) Comment(ctx context.Context, repo RepoRef, number int64, body string) error {
	path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", esc(repo.Owner), esc(repo.Name), number)
	return g.do(ctx, http.MethodPost, path, map[string]any{"body": body}, nil)
}

func (g *GiteaProvider) Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (string, error) {
	method := in.Method
	if method == "" {
		method = "merge"
	}
	body := map[string]any{"Do": method}
	if in.Title != "" {
		body["MergeTitleField"] = in.Title
	}
	if in.Message != "" {
		body["MergeMessageField"] = in.Message
	}
	path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", esc(repo.Owner), esc(repo.Name), number)
	if err := g.do(ctx, http.MethodPost, path, body, nil); err != nil {
		return "", err
	}
	// Gitea's merge endpoint returns 200 with no SHA; re-fetch to read the merge commit.
	pr, err := g.GetPR(ctx, repo, number)
	if err != nil {
		return "", nil // merge succeeded; sha lookup best-effort
	}
	return pr.MergeCommitSHA, nil
}

func (g *GiteaProvider) ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error) {
	q := url.Values{}
	q.Set("type", "issues")
	if opts.State != "" {
		q.Set("state", opts.State)
	}
	if opts.Limit > 0 {
		q.Set("limit", strconv.Itoa(opts.Limit))
	}
	path := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", esc(repo.Owner), esc(repo.Name), q.Encode())
	var rows []giteaIssue
	if err := g.do(ctx, http.MethodGet, path, nil, &rows); err != nil {
		return nil, err
	}
	out := make([]Issue, 0, len(rows))
	for _, r := range rows {
		out = append(out, Issue{Number: r.Number, Title: r.Title, State: r.State, HTMLURL: r.HTMLURL})
	}
	return out, nil
}

// ciState fetches the combined commit status for sha and maps it to the
// platform's ci_state vocabulary. Missing status / errors degrade to "unknown".
func (g *GiteaProvider) ciState(ctx context.Context, repo RepoRef, sha string) string {
	if sha == "" {
		return "unknown"
	}
	path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/status", esc(repo.Owner), esc(repo.Name), esc(sha))
	var cs giteaCombinedStatus
	if err := g.do(ctx, http.MethodGet, path, nil, &cs); err != nil {
		return "unknown"
	}
	switch cs.State {
	case "success":
		return "success"
	case "pending":
		return "pending"
	case "error", "failure":
		return "failure"
	default:
		return "unknown"
	}
}

// ===== HTTP plumbing =====

// do performs a request, sets the token auth header, and decodes a 2xx JSON
// body into out (out nil => body discarded). Non-2xx becomes a typed upstream
// error carrying a truncated body.
func (g *GiteaProvider) do(ctx context.Context, method, path string, body, out any) error {
	var rdr io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return errs.Wrap(err, errs.CodeInternal, "vcs: marshal request body")
		}
		rdr = bytes.NewReader(b)
	}
	req, err := http.NewRequestWithContext(ctx, method, g.baseURL+path, rdr)
	if err != nil {
		return errs.Wrap(err, errs.CodeInternal, "vcs: build request")
	}
	if g.token != "" {
		req.Header.Set("Authorization", "token "+g.token)
	}
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	resp, err := g.client.Do(req)
	if err != nil {
		return errs.Wrap(err, errs.CodeUpstream, "vcs: gitea request failed")
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, giteaErrBodyLimit))
		code := errs.CodeUpstream
		if resp.StatusCode == http.StatusNotFound {
			code = errs.CodeNotFound
		}
		return errs.New(code, fmt.Sprintf("vcs: gitea %s %s -> %d: %s",
			method, path, resp.StatusCode, strings.TrimSpace(string(snippet))))
	}
	if out == nil {
		_, _ = io.Copy(io.Discard, resp.Body)
		return nil
	}
	if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
		return errs.Wrap(err, errs.CodeUpstream, "vcs: decode gitea response")
	}
	return nil
}

func mapPR(pr giteaPR) PullRequest {
	state := pr.State
	if pr.Merged {
		state = "merged"
	}
	return PullRequest{
		Number:         pr.Number,
		State:          state,
		Mergeable:      pr.Mergeable,
		Merged:         pr.Merged,
		MergeCommitSHA: pr.MergedCommitID,
		HTMLURL:        pr.HTMLURL,
	}
}

// esc path-escapes a single URL segment (owner/name/sha may contain odd chars).
func esc(s string) string { return url.PathEscape(s) }
