package apiclient

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

func newTargetResolver(ttl time.Duration, capacity int, now func() time.Time) *TargetResolver {
	resolver := NewTargetResolver()
	resolver.ttl = ttl
	resolver.capacity = capacity
	if now != nil {
		resolver.now = now
	}
	return resolver
}

func newResolveTestClient(transport http.RoundTripper, resolver *TargetResolver) *Client {
	return &Client{
		ServerURL:        "http://agentteam.test",
		RouteWorkspaceID: "wsp_1",
		AgentID:          "agt_1",
		AuthToken:        "token",
		HTTPClient:       &http.Client{Transport: transport},
		TargetResolver:   resolver,
		retry:            deterministicRetryConfig([]time.Duration{}, nil),
	}
}

func resolveJSONResponse(req *http.Request, scopeID string, targetKey string) *http.Response {
	payload, _ := json.Marshal(map[string]string{
		"scopeId":   scopeID,
		"targetKey": targetKey,
	})
	return &http.Response{
		StatusCode: http.StatusOK,
		Header:     make(http.Header),
		Body:       io.NopCloser(strings.NewReader(string(payload))),
		Request:    req,
	}
}

func requireResolvedTarget(t *testing.T, resolved ResolvedTarget, scopeID string, targetKey string) {
	t.Helper()
	require.Equal(t, scopeID, resolved.ScopeID)
	require.Equal(t, targetKey, resolved.TargetKey)
}

func TestClientResolveTargetCachesPositiveResult(t *testing.T) {
	var attempts atomic.Int32
	client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
		attempts.Add(1)
		require.Equal(t, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/resolve-channel", req.URL.Path)
		var body map[string]string
		require.NoError(t, json.NewDecoder(req.Body).Decode(&body))
		require.Equal(t, "#ops", body["target"])
		require.Empty(t, body["purpose"])
		return resolveJSONResponse(req, "chn_1", ""), nil
	}), NewTargetResolver())

	first, err := client.ResolveTarget(context.Background(), " #ops ", ResolvePurposeWritable)
	require.NoError(t, err)
	second, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)

	requireResolvedTarget(t, first, "chn_1", "chn_1")
	requireResolvedTarget(t, second, "chn_1", "chn_1")
	require.Equal(t, int32(1), attempts.Load())
}

func TestClientResolveTargetNormalizesEquivalentDMTargets(t *testing.T) {
	var attempts atomic.Int32
	client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
		attempts.Add(1)
		return resolveJSONResponse(req, "dm_1", "dm_1"), nil
	}), NewTargetResolver())

	first, err := client.ResolveTarget(context.Background(), "DM:@yw1knp7", ResolvePurposeWritable)
	require.NoError(t, err)
	second, err := client.ResolveTarget(context.Background(), "dm:@yw1knp7", ResolvePurposeWritable)
	require.NoError(t, err)
	requireResolvedTarget(t, first, "dm_1", "dm_1")
	requireResolvedTarget(t, second, "dm_1", "dm_1")
	require.Equal(t, int32(1), attempts.Load())
}

func TestClientResolveTargetCacheKeyIsolation(t *testing.T) {
	var attempts atomic.Int32
	resolver := NewTargetResolver()
	transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
		scopeID := fmt.Sprintf("scope-%d", attempts.Add(1))
		return resolveJSONResponse(req, scopeID, scopeID), nil
	})
	base := newResolveTestClient(transport, resolver)

	first, err := base.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	again, err := base.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	require.Equal(t, first.ScopeID, again.ScopeID)
	require.Equal(t, first.TargetKey, again.TargetKey)

	otherServer := *base
	otherServer.ServerURL = "http://other.test"
	_, err = otherServer.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)

	otherWorkspace := *base
	otherWorkspace.RouteWorkspaceID = "wsp_2"
	_, err = otherWorkspace.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)

	otherAgent := *base
	otherAgent.AgentID = "agt_2"
	_, err = otherAgent.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)

	review, err := base.ResolveTarget(context.Background(), "#ops", ResolvePurposeReviewContext)
	require.NoError(t, err)
	require.NotEqual(t, first.ScopeID, review.ScopeID)
	require.Equal(t, int32(5), attempts.Load())
}

func TestClientResolveTargetDoesNotCacheErrorsOrEmptyScope(t *testing.T) {
	t.Run("empty scope", func(t *testing.T) {
		var attempts atomic.Int32
		client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
			attempts.Add(1)
			return resolveJSONResponse(req, "", ""), nil
		}), NewTargetResolver())

		for range 2 {
			_, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
			require.ErrorContains(t, err, "empty scope")
		}
		require.Equal(t, int32(2), attempts.Load())
	})

	t.Run("server error field", func(t *testing.T) {
		var attempts atomic.Int32
		client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
			attempts.Add(1)
			return &http.Response{
				StatusCode: http.StatusOK,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
				Request:    req,
			}, nil
		}), NewTargetResolver())

		for range 2 {
			_, err := client.ResolveTarget(context.Background(), "#missing", ResolvePurposeWritable)
			require.ErrorContains(t, err, "not found")
		}
		require.Equal(t, int32(2), attempts.Load())
	})
}

func TestTargetResolverTTLAndLRUEviction(t *testing.T) {
	t.Run("ttl", func(t *testing.T) {
		now := time.Unix(1_700_000_000, 0)
		resolver := newTargetResolver(time.Hour, 2, func() time.Time { return now })
		var attempts atomic.Int32
		client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
			scopeID := fmt.Sprintf("chn_%d", attempts.Add(1))
			return resolveJSONResponse(req, scopeID, scopeID), nil
		}), resolver)

		first, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
		require.NoError(t, err)
		now = now.Add(time.Hour)
		expired, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
		require.NoError(t, err)
		require.NotEqual(t, first.ScopeID, expired.ScopeID)
		require.Equal(t, int32(2), attempts.Load())
	})

	t.Run("lru", func(t *testing.T) {
		resolver := newTargetResolver(time.Hour, 2, time.Now)
		var attempts atomic.Int32
		client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
			var body map[string]string
			require.NoError(t, json.NewDecoder(req.Body).Decode(&body))
			scopeID := body["target"] + fmt.Sprintf("-%d", attempts.Add(1))
			return resolveJSONResponse(req, scopeID, scopeID), nil
		}), resolver)

		_, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
		require.NoError(t, err)
		_, err = client.ResolveTarget(context.Background(), "#dev", ResolvePurposeWritable)
		require.NoError(t, err)
		_, err = client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
		require.NoError(t, err) // promote ops, making dev least recently used
		_, err = client.ResolveTarget(context.Background(), "#sales", ResolvePurposeWritable)
		require.NoError(t, err)
		_, err = client.ResolveTarget(context.Background(), "#dev", ResolvePurposeWritable)
		require.NoError(t, err)
		require.Equal(t, int32(4), attempts.Load(), "dev must be reloaded after LRU eviction")
	})
}

func TestTargetResolverSingleflightCollapsesConcurrentMisses(t *testing.T) {
	var attempts atomic.Int32
	started := make(chan struct{})
	release := make(chan struct{})
	client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
		if attempts.Add(1) == 1 {
			close(started)
		}
		<-release
		return resolveJSONResponse(req, "chn_1", "chn_1"), nil
	}), NewTargetResolver())

	const callers = 16
	results := make(chan ResolvedTarget, callers)
	errors := make(chan error, callers)
	var wg sync.WaitGroup
	wg.Add(callers)
	for range callers {
		go func() {
			defer wg.Done()
			resolved, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
			results <- resolved
			errors <- err
		}()
	}
	<-started
	close(release)
	wg.Wait()
	close(results)
	close(errors)

	for err := range errors {
		require.NoError(t, err)
	}
	for resolved := range results {
		requireResolvedTarget(t, resolved, "chn_1", "chn_1")
	}
	require.Equal(t, int32(1), attempts.Load())
}

func TestTargetResolverInvalidationAndAgentRemoval(t *testing.T) {
	var attempts atomic.Int32
	resolver := NewTargetResolver()
	client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
		scopeID := fmt.Sprintf("chn_%d", attempts.Add(1))
		return resolveJSONResponse(req, scopeID, scopeID), nil
	}), resolver)

	first, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	require.False(t, first.InvalidateOnError(&APIError{StatusCode: http.StatusInternalServerError}))
	stillCached, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	requireResolvedTarget(t, stillCached, first.ScopeID, first.TargetKey)

	resolved := stillCached
	for _, status := range []int{http.StatusForbidden, http.StatusNotFound, http.StatusGone} {
		require.True(t, resolved.InvalidateOnError(&APIError{StatusCode: status}))
		resolved, err = client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
		require.NoError(t, err)
	}
	require.Equal(t, int32(4), attempts.Load())

	resolver.RemoveAgent("agt_1")
	_, err = client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	require.Equal(t, int32(5), attempts.Load())
}

func TestResolvedTargetStaleHandleDoesNotEvictReplacement(t *testing.T) {
	var attempts atomic.Int32
	client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
		scopeID := fmt.Sprintf("chn_%d", attempts.Add(1))
		return resolveJSONResponse(req, scopeID, scopeID), nil
	}), NewTargetResolver())
	staleError := &APIError{StatusCode: http.StatusGone}

	stale, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	require.True(t, stale.InvalidateOnError(staleError))

	replacement, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	require.False(t, stale.InvalidateOnError(staleError))

	cached, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	requireResolvedTarget(t, cached, replacement.ScopeID, replacement.TargetKey)
	require.Equal(t, int32(2), attempts.Load())
}

func TestTargetResolverRemoveAgentFencesInflightGeneration(t *testing.T) {
	var attempts atomic.Int32
	oldStarted := make(chan struct{})
	oldRelease := make(chan struct{})
	newStarted := make(chan struct{})
	client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
		switch attempts.Add(1) {
		case 1:
			close(oldStarted)
			<-oldRelease
			return resolveJSONResponse(req, "chn_old", "chn_old"), nil
		case 2:
			close(newStarted)
			return resolveJSONResponse(req, "chn_new", "chn_new"), nil
		default:
			return nil, fmt.Errorf("unexpected resolve attempt")
		}
	}), NewTargetResolver())

	oldResult := make(chan ResolvedTarget, 1)
	oldError := make(chan error, 1)
	go func() {
		resolved, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
		oldResult <- resolved
		oldError <- err
	}()
	<-oldStarted

	client.TargetResolver.RemoveAgent("agt_1")
	newResolved, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	<-newStarted
	requireResolvedTarget(t, newResolved, "chn_new", "chn_new")

	close(oldRelease)
	require.NoError(t, <-oldError)
	requireResolvedTarget(t, <-oldResult, "chn_old", "chn_old")

	cached, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
	require.NoError(t, err)
	requireResolvedTarget(t, cached, "chn_new", "chn_new")
	require.Equal(t, int32(2), attempts.Load())
}

func TestClientResolveTargetWithoutResolverDoesNotCache(t *testing.T) {
	var attempts atomic.Int32
	client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
		scopeID := fmt.Sprintf("chn_%d", attempts.Add(1))
		return resolveJSONResponse(req, scopeID, scopeID), nil
	}), nil)

	for range 2 {
		resolved, err := client.ResolveTarget(context.Background(), "#ops", ResolvePurposeWritable)
		require.NoError(t, err)
		require.False(t, resolved.InvalidateOnError(&APIError{StatusCode: http.StatusGone}))
	}
	require.Equal(t, int32(2), attempts.Load())
}

func TestClientResolveTargetPreservesPurposeInRequest(t *testing.T) {
	client := newResolveTestClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
		var body map[string]string
		require.NoError(t, json.NewDecoder(req.Body).Decode(&body))
		require.Equal(t, "#ops:deadbeef", body["target"])
		require.Equal(t, "review_context", body["purpose"])
		return resolveJSONResponse(req, "thr_1", "thr_1"), nil
	}), NewTargetResolver())

	resolved, err := client.ResolveTarget(
		context.Background(),
		" #ops:deadbeef ",
		ResolvePurposeReviewContext,
	)
	require.NoError(t, err)
	requireResolvedTarget(t, resolved, "thr_1", "thr_1")
}
