package sync

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
	"time"

	"bountyboard/internal/domain"
)

// fakeWekan implements just enough of the WeKan REST API: login, board with
// members, lists, cards, card details with assignees and timestamps.
func fakeWekan(t *testing.T, logins *atomic.Int64) *httptest.Server {
	t.Helper()
	mux := http.NewServeMux()
	authed := func(w http.ResponseWriter, r *http.Request) bool {
		if r.Header.Get("Authorization") != "Bearer wekan-token-1" {
			http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
			return false
		}
		return true
	}
	js := func(w http.ResponseWriter, v any) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(v)
	}

	mux.HandleFunc("POST /users/login", func(w http.ResponseWriter, r *http.Request) {
		var req struct{ Username, Password string }
		json.NewDecoder(r.Body).Decode(&req)
		if req.Username != "syncbot" || req.Password != "hunter2" {
			http.Error(w, `{"error":"bad credentials"}`, http.StatusUnauthorized)
			return
		}
		if logins != nil {
			logins.Add(1)
		}
		js(w, map[string]string{"id": "u-syncbot", "token": "wekan-token-1"})
	})
	mux.HandleFunc("GET /api/boards/board1", func(w http.ResponseWriter, r *http.Request) {
		if !authed(w, r) {
			return
		}
		js(w, map[string]any{
			"title": "Sprint Board", "slug": "sprint-board",
			"members": []map[string]any{
				{"userId": "u-clara"}, {"userId": "u-other"}, {"userId": "u-syncbot"},
			},
		})
	})
	mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
		if !authed(w, r) {
			return
		}
		names := map[string]string{"u-clara": "clara", "u-other": "othello", "u-syncbot": "syncbot"}
		js(w, map[string]string{"username": names[r.PathValue("id")]})
	})
	mux.HandleFunc("GET /api/boards/board1/lists", func(w http.ResponseWriter, r *http.Request) {
		if !authed(w, r) {
			return
		}
		js(w, []map[string]string{{"_id": "l-todo", "title": "Todo"}, {"_id": "l-doing", "title": "Doing"}})
	})
	mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards", func(w http.ResponseWriter, r *http.Request) {
		if !authed(w, r) {
			return
		}
		switch r.PathValue("list") {
		case "l-todo":
			js(w, []map[string]string{{"_id": "card-a"}, {"_id": "card-b"}, {"_id": "card-d"}})
		default:
			js(w, []map[string]string{{"_id": "card-c"}})
		}
	})
	cards := map[string]map[string]any{
		// assigned to clara, recently modified
		"card-a": {"_id": "card-a", "title": "Fix the login flow", "description": "details A",
			"assignees": []string{"u-clara"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)},
		// member-fallback assignment, old timestamp
		"card-b": {"_id": "card-b", "title": "Old card", "description": "details B",
			"assignees": []string{}, "members": []string{"u-clara"},
			"modifiedAt": "2020-01-01T00:00:00Z"},
		// assigned to someone else
		"card-c": {"_id": "card-c", "title": "Not clara's", "description": "details C",
			"assignees": []string{"u-other"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)},
		// clara's but archived
		"card-d": {"_id": "card-d", "title": "Archived card", "description": "details D",
			"assignees": []string{"u-clara"}, "archived": true,
			"modifiedAt": time.Now().UTC().Format(time.RFC3339)},
	}
	mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards/{card}", func(w http.ResponseWriter, r *http.Request) {
		if !authed(w, r) {
			return
		}
		c, ok := cards[r.PathValue("card")]
		if !ok {
			http.NotFound(w, r)
			return
		}
		js(w, c)
	})

	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv
}

func newWekan(t *testing.T, srv *httptest.Server) Connector {
	t.Helper()
	conn, err := NewConnector(domain.TicketingWekan, srv.URL, "board1",
		Credentials{"username": "syncbot", "password": "hunter2"})
	if err != nil {
		t.Fatal(err)
	}
	return conn
}

func TestWekanFactoryValidation(t *testing.T) {
	if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1", Credentials{}); err == nil {
		t.Fatal("missing credentials must be rejected")
	}
	if _, err := NewConnector(domain.TicketingWekan, "", "board1",
		Credentials{"username": "u", "password": "p"}); err == nil {
		t.Fatal("missing baseUrl must be rejected")
	}
	if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "",
		Credentials{"username": "u", "password": "p"}); err == nil {
		t.Fatal("missing board id must be rejected")
	}
	if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1",
		Credentials{"token": "tok", "userId": "u1"}); err != nil {
		t.Fatalf("pre-issued token must be accepted: %v", err)
	}
}

func TestWekanTestConnection(t *testing.T) {
	srv := fakeWekan(t, nil)
	if err := newWekan(t, srv).TestConnection(context.Background()); err != nil {
		t.Fatalf("test connection: %v", err)
	}

	bad, err := NewConnector(domain.TicketingWekan, srv.URL, "board1",
		Credentials{"username": "syncbot", "password": "wrong"})
	if err != nil {
		t.Fatal(err)
	}
	if err := bad.TestConnection(context.Background()); err == nil {
		t.Fatal("wrong password must fail the connection test")
	}
}

func TestWekanFetchUpdated(t *testing.T) {
	srv := fakeWekan(t, nil)
	conn := newWekan(t, srv)

	// since the epoch: clara gets card-a (assignee) and card-b (member
	// fallback); card-c is someone else's, card-d is archived
	tickets, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0))
	if err != nil {
		t.Fatal(err)
	}
	if len(tickets) != 2 {
		t.Fatalf("tickets = %d, want 2 (%+v)", len(tickets), tickets)
	}
	byKey := map[string]Ticket{}
	for _, tk := range tickets {
		byKey[tk.Key] = tk
	}
	a, ok := byKey["card-a"]
	if !ok {
		t.Fatal("card-a missing")
	}
	if a.Title != "Fix the login flow" || a.Type != "task" {
		t.Fatalf("card-a mapped wrong: %+v", a)
	}
	if a.URL != srv.URL+"/b/board1/sprint-board/card-a" {
		t.Fatalf("card url: %s", a.URL)
	}
	if a.Raw["list"] != "Todo" || a.Raw["board"] != "Sprint Board" {
		t.Fatalf("raw payload: %+v", a.Raw)
	}

	// recent since-watermark filters out the old card-b
	tickets, err = conn.FetchUpdated(context.Background(), "clara", time.Now().Add(-time.Hour))
	if err != nil {
		t.Fatal(err)
	}
	if len(tickets) != 1 || tickets[0].Key != "card-a" {
		t.Fatalf("since filter: %+v", tickets)
	}

	// identity casing is forgiving
	if _, err := conn.FetchUpdated(context.Background(), "CLARA", time.Unix(0, 0)); err != nil {
		t.Fatalf("case-insensitive identity: %v", err)
	}

	// unknown identity errors clearly
	if _, err := conn.FetchUpdated(context.Background(), "nobody", time.Unix(0, 0)); err == nil {
		t.Fatal("unknown wekan user must error")
	}
}

func TestWekanListAssignedKeysAndTokenReuse(t *testing.T) {
	var logins atomic.Int64
	srv := fakeWekan(t, &logins)
	conn := newWekan(t, srv)

	keys, err := conn.ListAssignedKeys(context.Background(), "clara")
	if err != nil {
		t.Fatal(err)
	}
	if len(keys) != 2 {
		t.Fatalf("keys = %v, want card-a + card-b", keys)
	}
	// several operations reuse one login token instead of re-authenticating
	if _, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0)); err != nil {
		t.Fatal(err)
	}
	if logins.Load() != 1 {
		t.Fatalf("logins = %d, want 1 (token reuse)", logins.Load())
	}
}
