package staticsite

import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

// ── State mapping (pure) ────────────────────────────────────────────
//
// Each provider maps the provider-agnostic CommitStatus onto its own API
// vocabulary. Gitea/GitHub accept the same four words; GitLab collapses
// failure+error into "failed".

func TestGiteaState_Mapping(t *testing.T) {
	cases := map[CommitStatus]string{
		CommitStatusPending: "pending",
		CommitStatusSuccess: "success",
		CommitStatusFailure: "failure",
		CommitStatusError:   "error",
		CommitStatus("???"): "pending", // unknown -> pending fallback
	}
	for in, want := range cases {
		if got := giteaState(in); got != want {
			t.Errorf("giteaState(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestGitHubState_Mapping(t *testing.T) {
	cases := map[CommitStatus]string{
		CommitStatusPending: "pending",
		CommitStatusSuccess: "success",
		CommitStatusFailure: "failure",
		CommitStatusError:   "error",
		CommitStatus("???"): "pending",
	}
	for in, want := range cases {
		if got := githubState(in); got != want {
			t.Errorf("githubState(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestGitLabState_Mapping(t *testing.T) {
	cases := map[CommitStatus]string{
		CommitStatusPending: "pending",
		CommitStatusSuccess: "success",
		CommitStatusFailure: "failed", // GitLab has no "failure"
		CommitStatusError:   "failed", // error also collapses to "failed"
		CommitStatus("???"): "pending",
	}
	for in, want := range cases {
		if got := gitlabState(in); got != want {
			t.Errorf("gitlabState(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestTruncateDescription(t *testing.T) {
	short := "Tinyforge: deploying"
	if got := truncateDescription(short); got != short {
		t.Errorf("short description mutated: %q", got)
	}
	long := strings.Repeat("x", 200)
	got := truncateDescription(long)
	if len([]rune(got)) > maxCommitStatusDescription {
		t.Errorf("truncated length = %d runes, want <= %d", len([]rune(got)), maxCommitStatusDescription)
	}
	if !strings.HasSuffix(got, "…") {
		t.Errorf("missing ellipsis on truncation: %q", got)
	}
}

// ── Endpoint + body construction (httptest) ─────────────────────────
//
// The SSRF-safe client refuses loopback, so for these tests we swap the
// provider's httpClient for a plain one pointed at httptest. This still
// exercises the real URL/body construction inside each SetCommitStatus.

type capturedRequest struct {
	method string
	path   string // r.URL.EscapedPath() — preserves %2F so GitLab's encoded project path is observable
	rawQ   string
	body   map[string]string
	auth   string
	token  string // PRIVATE-TOKEN (GitLab)
}

func newCaptureServer(t *testing.T, capture *capturedRequest) *httptest.Server {
	t.Helper()
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		capture.method = r.Method
		capture.path = r.URL.EscapedPath()
		capture.rawQ = r.URL.RawQuery
		capture.auth = r.Header.Get("Authorization")
		capture.token = r.Header.Get("PRIVATE-TOKEN")
		raw, _ := io.ReadAll(r.Body)
		if len(raw) > 0 {
			_ = json.Unmarshal(raw, &capture.body)
		}
		w.WriteHeader(http.StatusCreated)
		_, _ = w.Write([]byte(`{}`))
	}))
}

func TestGitea_SetCommitStatus_Request(t *testing.T) {
	var cap capturedRequest
	srv := newCaptureServer(t, &cap)
	defer srv.Close()

	f := NewGiteaContentFetcher(srv.URL, "tok123")
	f.httpClient = srv.Client() // bypass SSRF guard for loopback test server

	err := f.SetCommitStatus(context.Background(), "owner", "repo", "abc123",
		CommitStatusSuccess, "https://app.example.com", "deployed")
	if err != nil {
		t.Fatalf("SetCommitStatus: %v", err)
	}

	if cap.method != http.MethodPost {
		t.Errorf("method = %q, want POST", cap.method)
	}
	if want := "/api/v1/repos/owner/repo/statuses/abc123"; cap.path != want {
		t.Errorf("path = %q, want %q", cap.path, want)
	}
	if cap.body["state"] != "success" {
		t.Errorf("state = %q, want success", cap.body["state"])
	}
	if cap.body["context"] != "tinyforge" {
		t.Errorf("context = %q, want tinyforge", cap.body["context"])
	}
	if cap.body["target_url"] != "https://app.example.com" {
		t.Errorf("target_url = %q", cap.body["target_url"])
	}
	if cap.body["description"] != "deployed" {
		t.Errorf("description = %q, want deployed", cap.body["description"])
	}
	if cap.auth != "token tok123" {
		t.Errorf("auth = %q, want 'token tok123'", cap.auth)
	}
}

func TestGitHub_SetCommitStatus_Request(t *testing.T) {
	var cap capturedRequest
	srv := newCaptureServer(t, &cap)
	defer srv.Close()

	// Force GHE-style apiBase so we hit the server's path; the github.com
	// branch hard-codes api.github.com which the SSRF client would block.
	g := NewGitHubProvider(srv.URL, "ghp_tok")
	g.apiBase = srv.URL + "/api/v3"
	g.httpClient = srv.Client()

	err := g.SetCommitStatus(context.Background(), "octo", "cat", "deadbeef",
		CommitStatusFailure, "", "failed")
	if err != nil {
		t.Fatalf("SetCommitStatus: %v", err)
	}

	if want := "/api/v3/repos/octo/cat/statuses/deadbeef"; cap.path != want {
		t.Errorf("path = %q, want %q", cap.path, want)
	}
	if cap.body["state"] != "failure" {
		t.Errorf("state = %q, want failure", cap.body["state"])
	}
	if cap.body["context"] != "tinyforge" {
		t.Errorf("context = %q, want tinyforge", cap.body["context"])
	}
	if cap.auth != "Bearer ghp_tok" {
		t.Errorf("auth = %q, want 'Bearer ghp_tok'", cap.auth)
	}
}

func TestGitLab_SetCommitStatus_Request(t *testing.T) {
	var cap capturedRequest
	srv := newCaptureServer(t, &cap)
	defer srv.Close()

	g := NewGitLabProvider(srv.URL, "glpat-xyz")
	g.httpClient = srv.Client()

	err := g.SetCommitStatus(context.Background(), "grp", "proj", "cafe01",
		CommitStatusError, "https://app.example.com", "boom")
	if err != nil {
		t.Fatalf("SetCommitStatus: %v", err)
	}

	// GitLab uses the URL-encoded project path + sha in the path, and the
	// status metadata as query params.
	if want := "/api/v4/projects/grp%2Fproj/statuses/cafe01"; cap.path != want {
		t.Errorf("path = %q, want %q", cap.path, want)
	}
	q, err := parseQuery(cap.rawQ)
	if err != nil {
		t.Fatalf("parse query %q: %v", cap.rawQ, err)
	}
	if q["state"] != "failed" { // error -> failed
		t.Errorf("state = %q, want failed", q["state"])
	}
	if q["name"] != "tinyforge" {
		t.Errorf("name = %q, want tinyforge", q["name"])
	}
	if q["target_url"] != "https://app.example.com" {
		t.Errorf("target_url = %q", q["target_url"])
	}
	if q["description"] != "boom" {
		t.Errorf("description = %q, want boom", q["description"])
	}
	if cap.token != "glpat-xyz" {
		t.Errorf("PRIVATE-TOKEN = %q, want glpat-xyz", cap.token)
	}
}

// parseQuery is a tiny wrapper so the test reads the first value of each
// query key without dragging net/url into every assertion.
func parseQuery(raw string) (map[string]string, error) {
	out := map[string]string{}
	if raw == "" {
		return out, nil
	}
	for _, pair := range strings.Split(raw, "&") {
		kv := strings.SplitN(pair, "=", 2)
		k := urlDecode(kv[0])
		v := ""
		if len(kv) == 2 {
			v = urlDecode(kv[1])
		}
		if _, ok := out[k]; !ok {
			out[k] = v
		}
	}
	return out, nil
}

func urlDecode(s string) string {
	dec, err := decodeQueryComponent(s)
	if err != nil {
		return s
	}
	return dec
}

// decodeQueryComponent decodes one application/x-www-form-urlencoded
// component (handles %XX and '+'-as-space) without importing net/url here.
func decodeQueryComponent(s string) (string, error) {
	var b strings.Builder
	for i := 0; i < len(s); i++ {
		switch s[i] {
		case '+':
			b.WriteByte(' ')
		case '%':
			if i+2 >= len(s) {
				return s, errPercent
			}
			hi, lo := fromHex(s[i+1]), fromHex(s[i+2])
			if hi < 0 || lo < 0 {
				return s, errPercent
			}
			b.WriteByte(byte(hi<<4 | lo))
			i += 2
		default:
			b.WriteByte(s[i])
		}
	}
	return b.String(), nil
}

var errPercent = &decodeErr{}

type decodeErr struct{}

func (*decodeErr) Error() string { return "bad percent-encoding" }

func fromHex(c byte) int {
	switch {
	case c >= '0' && c <= '9':
		return int(c - '0')
	case c >= 'a' && c <= 'f':
		return int(c-'a') + 10
	case c >= 'A' && c <= 'F':
		return int(c-'A') + 10
	}
	return -1
}

// TestSetCommitStatus_NonOK_ReturnsError verifies a non-2xx provider
// response surfaces as an error (the deploy hook logs + swallows it, but
// the provider method itself must report it).
func TestSetCommitStatus_NonOK_ReturnsError(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = w.Write([]byte(`{"message":"bad token"}`))
	}))
	defer srv.Close()

	f := NewGiteaContentFetcher(srv.URL, "tok")
	f.httpClient = srv.Client()

	err := f.SetCommitStatus(context.Background(), "o", "r", "sha", CommitStatusPending, "", "x")
	if err == nil {
		t.Fatal("expected error on 401, got nil")
	}
	if !strings.Contains(err.Error(), "401") {
		t.Errorf("error missing status code: %v", err)
	}
}

// TestSetCommitStatus_RespectsContext ensures the call honours context
// cancellation (defensive — the deploy hook passes the deploy ctx).
func TestSetCommitStatus_RespectsContext(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(200 * time.Millisecond)
		w.WriteHeader(http.StatusCreated)
	}))
	defer srv.Close()

	f := NewGiteaContentFetcher(srv.URL, "")
	f.httpClient = srv.Client()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
	defer cancel()
	if err := f.SetCommitStatus(ctx, "o", "r", "sha", CommitStatusPending, "", "x"); err == nil {
		t.Fatal("expected context-deadline error, got nil")
	}
}
