package services

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

	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
)

// AichatPaliadinService unit tests (t-paliad-194 / m/paliad#38).
//
// Every test bypasses the HTTP wire via the httpHook field — no real
// requests are issued, no DB rows are written. Tests that would need DB
// I/O (audit row insert/complete on RunTurn) are not in scope here;
// paliad's test suite has no sqlx mock and the existing paliadin tests
// only cover pure functions and hookable interfaces.

const testAichatBase = "http://aichat.test"
const testAichatToken = "raw-app-token"

// newAichatService builds an AichatPaliadinService with a baked-in hook
// for tests. The hook receives every callHTTP invocation; tests cusomise
// what it returns.
func newAichatService(t *testing.T, secret []byte, hook func(ctx context.Context, method, path string, body any, out any) error) *AichatPaliadinService {
	t.Helper()
	s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
		BaseURL:     testAichatBase,
		BearerToken: testAichatToken,
		JWTSecret:   secret,
	})
	s.httpHook = hook
	return s
}

// =============================================================================
// Constructor + defaults
// =============================================================================

func TestNewAichatPaliadinService_Defaults(t *testing.T) {
	s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
		BaseURL:     testAichatBase + "/",
		BearerToken: "t",
	})
	if s.cfg.Persona != DefaultAichatPersona {
		t.Errorf("Persona default = %q; want %q", s.cfg.Persona, DefaultAichatPersona)
	}
	if s.cfg.HTTPClient == nil {
		t.Error("HTTPClient should be defaulted, not nil")
	}
	if s.cfg.BaseURL != testAichatBase {
		t.Errorf("BaseURL trailing slash not trimmed: %q", s.cfg.BaseURL)
	}
	if s.cfg.HTTPClient.Timeout != DefaultAichatHTTPTimeout {
		t.Errorf("HTTPClient.Timeout = %s; want %s", s.cfg.HTTPClient.Timeout, DefaultAichatHTTPTimeout)
	}
}

func TestNewAichatPaliadinService_HonoursOverrides(t *testing.T) {
	custom := &http.Client{Timeout: 5 * time.Second}
	s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
		BaseURL:     testAichatBase,
		BearerToken: "t",
		Persona:     "custom",
		HTTPClient:  custom,
	})
	if s.cfg.Persona != "custom" {
		t.Errorf("Persona override lost: %q", s.cfg.Persona)
	}
	if s.cfg.HTTPClient != custom {
		t.Error("HTTPClient override lost")
	}
}

// =============================================================================
// Interface conformance
// =============================================================================

func TestAichatPaliadinService_ImplementsPaliadin(t *testing.T) {
	var _ Paliadin = (*AichatPaliadinService)(nil)
}

// =============================================================================
// Health gate
// =============================================================================

func TestAichatHealthGate_CachesOnSuccess(t *testing.T) {
	var calls int32
	s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
		atomic.AddInt32(&calls, 1)
		if method != http.MethodGet || path != "/chat/health" {
			t.Errorf("unexpected callHTTP: method=%s path=%s", method, path)
		}
		setHealthResp(out, true)
		return nil
	})
	for i := 0; i < 5; i++ {
		if err := s.healthGate(context.Background()); err != nil {
			t.Fatalf("healthGate iter %d: %v", i, err)
		}
	}
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Errorf("expected 1 health probe (cached); got %d", got)
	}
}

func TestAichatHealthGate_RetriesAfterFailure(t *testing.T) {
	var calls int32
	s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
		atomic.AddInt32(&calls, 1)
		return errors.New("dial tcp: connection refused")
	})
	for i := 0; i < 3; i++ {
		err := s.healthGate(context.Background())
		if !errors.Is(err, ErrMRiverUnreachable) {
			t.Errorf("iter %d: err %v; want wrap of ErrMRiverUnreachable", i, err)
		}
	}
	// Failed health is NOT cached.
	if got := atomic.LoadInt32(&calls); got != 3 {
		t.Errorf("expected 3 probes (no cache on failure); got %d", got)
	}
}

func TestAichatHealthGate_RejectsNotOK(t *testing.T) {
	s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
		setHealthResp(out, false)
		return nil
	})
	err := s.healthGate(context.Background())
	if !errors.Is(err, ErrMRiverUnreachable) {
		t.Errorf("err = %v; want wrap of ErrMRiverUnreachable for ok:false", err)
	}
}

func TestAichatHealthGate_CacheExpires(t *testing.T) {
	var calls int32
	s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
		atomic.AddInt32(&calls, 1)
		setHealthResp(out, true)
		return nil
	})
	if err := s.healthGate(context.Background()); err != nil {
		t.Fatalf("first probe: %v", err)
	}
	// Force the cached timestamp to expire.
	s.healthMu.Lock()
	s.healthCheckedAt = time.Now().Add(-11 * time.Second)
	s.healthMu.Unlock()
	if err := s.healthGate(context.Background()); err != nil {
		t.Fatalf("second probe: %v", err)
	}
	if got := atomic.LoadInt32(&calls); got != 2 {
		t.Errorf("expected 2 probes (cache expired); got %d", got)
	}
}

// =============================================================================
// ResetSession
// =============================================================================

func TestAichatResetSession_Posts(t *testing.T) {
	var captured aichatResetRequest
	s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
		if method != http.MethodPost || path != "/chat/reset" {
			t.Errorf("unexpected: method=%s path=%s", method, path)
		}
		req, ok := body.(aichatResetRequest)
		if !ok {
			t.Fatalf("body type %T; want aichatResetRequest", body)
		}
		captured = req
		setResetResp(out, true)
		return nil
	})
	uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
	if err := s.ResetSession(context.Background(), uid); err != nil {
		t.Fatalf("ResetSession: %v", err)
	}
	if captured.Persona != DefaultAichatPersona {
		t.Errorf("persona = %q; want %q", captured.Persona, DefaultAichatPersona)
	}
	// No DB → usernameFor falls back to "user-<uuid8>".
	if captured.Username != "user-aaaaaaaa" {
		t.Errorf("username = %q; want fallback user-aaaaaaaa", captured.Username)
	}
}

func TestAichatResetSession_HonoursServerError(t *testing.T) {
	s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
		return errors.New("aichat: HTTP 500: tmux unreachable")
	})
	uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
	if err := s.ResetSession(context.Background(), uid); err == nil {
		t.Fatal("expected error")
	}
}

func TestAichatResetSession_DropsPrimerCache(t *testing.T) {
	s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
		switch path {
		case "/chat/reset":
			setResetResp(out, true)
		default:
			t.Errorf("unexpected path: %s", path)
		}
		return nil
	})
	uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
	session := s.cfg.Persona + ":" + "user-aaaaaaaa"
	s.markPrimed(session)
	if !s.isPrimed(session) {
		t.Fatal("primer cache should be warm before reset")
	}
	if err := s.ResetSession(context.Background(), uid); err != nil {
		t.Fatalf("ResetSession: %v", err)
	}
	if s.isPrimed(session) {
		t.Error("ResetSession must drop the primer cache")
	}
}

// =============================================================================
// Error classification
// =============================================================================

func TestClassifyAichatError(t *testing.T) {
	cases := []struct {
		name string
		err  error
		want string
	}{
		{"nil", nil, ""},
		{"ErrMRiverUnreachable", ErrMRiverUnreachable, "mriver_unreachable"},
		{"wrapped ErrMRiverUnreachable", fmt.Errorf("foo: %w", ErrMRiverUnreachable), "mriver_unreachable"},
		{"ErrAichatAuthFailed", ErrAichatAuthFailed, "shim_auth_failed"},
		{"wrapped ErrAichatAuthFailed", fmt.Errorf("call: %w", ErrAichatAuthFailed), "shim_auth_failed"},
		{"ErrAichatPersonaUnknown", ErrAichatPersonaUnknown, "shim_error"},
		{"context deadline", context.DeadlineExceeded, "timeout"},
		{"aichat turn timeout msg", errors.New("aichat: turn timeout: response not written within 120s"), "timeout"},
		{"connection refused", errors.New("aichat: POST /chat/turn: dial tcp: connection refused"), "mriver_unreachable"},
		{"no such host", errors.New("aichat: GET /chat/health: dial tcp: lookup aichat.test: no such host"), "mriver_unreachable"},
		{"unknown error", errors.New("aichat: HTTP 502: bad gateway"), "shim_error"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := classifyAichatError(c.err)
			if got != c.want {
				t.Errorf("classifyAichatError(%v) = %q; want %q", c.err, got, c.want)
			}
		})
	}
}

// =============================================================================
// Error envelope decoding
// =============================================================================

func TestDecodeAichatError_MapsCodes(t *testing.T) {
	cases := []struct {
		name       string
		status     int
		body       string
		wantSentinel error
		wantSubstr string
	}{
		{
			name:       "auth_failed → ErrAichatAuthFailed",
			status:     401,
			body:       `{"error":{"code":"auth_failed","message":"bad token","retryable":false}}`,
			wantSentinel: ErrAichatAuthFailed,
			wantSubstr: "bad token",
		},
		{
			name:       "persona_unknown → ErrAichatPersonaUnknown",
			status:     403,
			body:       `{"error":{"code":"persona_unknown","message":"app not allowed"}}`,
			wantSentinel: ErrAichatPersonaUnknown,
			wantSubstr: "app not allowed",
		},
		{
			name:       "mriver_unreachable → ErrMRiverUnreachable",
			status:     503,
			body:       `{"error":{"code":"mriver_unreachable","message":"tmux missing"}}`,
			wantSentinel: ErrMRiverUnreachable,
			wantSubstr: "tmux missing",
		},
		{
			name:       "bootstrap_failed → ErrMRiverUnreachable",
			status:     500,
			body:       `{"error":{"code":"bootstrap_failed","message":"window stuck"}}`,
			wantSentinel: ErrMRiverUnreachable,
			wantSubstr: "window stuck",
		},
		{
			name:       "timeout has no sentinel but is recognisable",
			status:     504,
			body:       `{"error":{"code":"timeout","message":"no response"}}`,
			wantSentinel: nil,
			wantSubstr: "turn timeout",
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			err := decodeAichatError(c.status, []byte(c.body))
			if err == nil {
				t.Fatal("expected non-nil error")
			}
			if c.wantSentinel != nil && !errors.Is(err, c.wantSentinel) {
				t.Errorf("err = %v; want errors.Is to be %v", err, c.wantSentinel)
			}
			if !strings.Contains(err.Error(), c.wantSubstr) {
				t.Errorf("err msg %q; want substring %q", err.Error(), c.wantSubstr)
			}
		})
	}
}

func TestDecodeAichatError_FallsBackOnBadJSON(t *testing.T) {
	err := decodeAichatError(500, []byte("not json"))
	if err == nil {
		t.Fatal("expected error")
	}
	if !strings.Contains(err.Error(), "500") {
		t.Errorf("err should mention status: %v", err)
	}
}

// =============================================================================
// callHTTP wire format (no httpHook — uses RoundTripper instead)
// =============================================================================

// roundTripFunc lets a test inject a custom http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
	return f(r)
}

func TestCallHTTP_AttachesBearerAndJSON(t *testing.T) {
	var seen *http.Request
	var seenBody []byte
	s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
		BaseURL:     testAichatBase,
		BearerToken: testAichatToken,
		HTTPClient: &http.Client{
			Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
				seen = r
				if r.Body != nil {
					seenBody, _ = io.ReadAll(r.Body)
				}
				resp := `{"ok":true,"claude_reachable":true,"tmux_reachable":true}`
				return &http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(resp)),
					Header:     http.Header{"Content-Type": []string{"application/json"}},
				}, nil
			}),
		},
	})
	var out aichatHealthResponse
	if err := s.callHTTP(context.Background(), http.MethodPost, "/chat/turn",
		map[string]string{"k": "v"}, &out); err != nil {
		t.Fatalf("callHTTP: %v", err)
	}
	if seen == nil {
		t.Fatal("no request captured")
	}
	if got := seen.Header.Get("Authorization"); got != "Bearer "+testAichatToken {
		t.Errorf("Authorization = %q; want Bearer %s", got, testAichatToken)
	}
	if got := seen.Header.Get("Content-Type"); got != "application/json" {
		t.Errorf("Content-Type = %q; want application/json", got)
	}
	if seen.URL.String() != testAichatBase+"/chat/turn" {
		t.Errorf("URL = %q; want %s/chat/turn", seen.URL.String(), testAichatBase)
	}
	var decoded map[string]string
	if err := json.Unmarshal(seenBody, &decoded); err != nil {
		t.Fatalf("body not JSON: %v (%s)", err, string(seenBody))
	}
	if decoded["k"] != "v" {
		t.Errorf("body lost: %v", decoded)
	}
}

func TestCallHTTP_DecodesErrorEnvelope(t *testing.T) {
	s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
		BaseURL:     testAichatBase,
		BearerToken: testAichatToken,
		HTTPClient: &http.Client{
			Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
				resp := `{"error":{"code":"auth_failed","message":"bad token","retryable":false}}`
				return &http.Response{
					StatusCode: 401,
					Body:       io.NopCloser(bytes.NewBufferString(resp)),
				}, nil
			}),
		},
	})
	err := s.callHTTP(context.Background(), http.MethodPost, "/chat/turn", map[string]string{}, nil)
	if !errors.Is(err, ErrAichatAuthFailed) {
		t.Errorf("err = %v; want ErrAichatAuthFailed", err)
	}
}

// =============================================================================
// JWT mint integration
// =============================================================================

func TestMintJWTIfConfigured_Disabled(t *testing.T) {
	s := newAichatService(t, nil, nil)
	tok, err := s.mintJWTIfConfigured(uuid.New())
	if err != nil {
		t.Errorf("err with empty secret: %v", err)
	}
	if tok != "" {
		t.Errorf("token = %q; want empty when secret unset", tok)
	}
}

func TestMintJWTIfConfigured_Signs(t *testing.T) {
	secret := []byte("test-secret-only-for-paliadin")
	s := newAichatService(t, secret, nil)
	uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
	tok, err := s.mintJWTIfConfigured(uid)
	if err != nil {
		t.Fatalf("mint: %v", err)
	}
	if strings.Count(tok, ".") != 2 {
		t.Errorf("token shape = %q; want 3-segment JWT", tok)
	}
	parsed, err := jwt.Parse(tok, func(*jwt.Token) (any, error) { return secret, nil },
		jwt.WithValidMethods([]string{"HS256"}))
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	claims := parsed.Claims.(jwt.MapClaims)
	if got, _ := claims["sub"].(string); got != uid.String() {
		t.Errorf("sub = %q; want %q", got, uid.String())
	}
	if got, _ := claims["role"].(string); got != "authenticated" {
		t.Errorf("role = %q; want authenticated", got)
	}
}

// =============================================================================
// RunTurn — exercises the full happy path with a hook + nil DB
// =============================================================================

// runTurnTestingService is a focused variant of AichatPaliadinService
// that skips the DB write in RunTurn. We can't mock sqlx cheaply, so we
// test the HTTP-facing surface of RunTurn directly via callHTTP rather
// than the public RunTurn entry point. The interface contract is still
// verified at compile time (TestAichatPaliadinService_ImplementsPaliadin).
//
// What we cover here:
//   - request body shape (persona, username, message, meta, primer, jwt)
//   - response decoding (pane_spawned → primer cache cleared)
//   - error path (callHTTP error → propagates)
func TestRunTurn_HappyPath_ViaCallHTTP(t *testing.T) {
	var captured aichatTurnRequest
	s := newAichatService(t, []byte("secret"), func(ctx context.Context, method, path string, body any, out any) error {
		switch path {
		case "/chat/health":
			setHealthResp(out, true)
			return nil
		case "/chat/turn":
			req, ok := body.(aichatTurnRequest)
			if !ok {
				return fmt.Errorf("unexpected body type: %T", body)
			}
			captured = req
			setTurnResp(out, "Hi back!", false)
			return nil
		}
		return fmt.Errorf("unexpected path: %s", path)
	})

	// RunTurn itself calls insertTurnRow on the DB. Without a real DB we
	// can't invoke RunTurn directly. Instead, simulate its inner sequence
	// at the HTTP level — same wire format, same hook, same response.
	// The DB-touching paths (insertTurnRow / completeTurn / markTurnError)
	// are covered by paliadin_test.go's existing audit-row tests.

	if err := s.healthGate(context.Background()); err != nil {
		t.Fatalf("healthGate: %v", err)
	}

	uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
	jwtTok, _ := s.mintJWTIfConfigured(uid)
	body := aichatTurnRequest{
		Persona:  s.cfg.Persona,
		Username: s.usernameFor(context.Background(), uid),
		Message:  "Hello",
		JWT:      jwtTok,
		Meta:     buildAichatMeta(TurnRequest{PageOrigin: "/dashboard"}),
	}
	var resp aichatTurnResponse
	if err := s.callHTTP(context.Background(), http.MethodPost, "/chat/turn", body, &resp); err != nil {
		t.Fatalf("callHTTP: %v", err)
	}

	if captured.Persona != DefaultAichatPersona {
		t.Errorf("persona = %q; want %q", captured.Persona, DefaultAichatPersona)
	}
	if captured.Username != "user-aaaaaaaa" {
		t.Errorf("username = %q; want user-aaaaaaaa (nil DB fallback)", captured.Username)
	}
	if captured.Message != "Hello" {
		t.Errorf("message = %q; want Hello", captured.Message)
	}
	if captured.JWT == "" {
		t.Error("JWT not attached; want signed token")
	}
	if captured.Meta["page_origin"] != "/dashboard" {
		t.Errorf("meta.page_origin = %q; want /dashboard", captured.Meta["page_origin"])
	}
	if resp.Response != "Hi back!" {
		t.Errorf("response = %q; want Hi back!", resp.Response)
	}
}

// =============================================================================
// usernameFor / buildAichatMeta / coerceAichatRowsSeen
// =============================================================================

func TestUsernameFor_FallbackWhenNoDB(t *testing.T) {
	s := newAichatService(t, nil, nil)
	uid := uuid.MustParse("12345678-1111-2222-3333-444444444444")
	if got := s.usernameFor(context.Background(), uid); got != "user-12345678" {
		t.Errorf("username = %q; want user-12345678", got)
	}
}

func TestBuildAichatMeta_OmitsEmpty(t *testing.T) {
	if buildAichatMeta(TurnRequest{}) != nil {
		t.Error("empty req should produce nil meta")
	}
}

func TestBuildAichatMeta_PacksTurnContext(t *testing.T) {
	req := TurnRequest{
		PageOrigin: "/projects/abc",
		Context: &TurnContext{
			RouteName:         "projects.detail",
			PrimaryEntityType: "project",
			PrimaryEntityID:   "abc-123",
			ViewMode:          "verlauf",
			FilterSummary:     "status=open",
			UserSelectionText: "selected phrase",
		},
	}
	meta := buildAichatMeta(req)
	if meta == nil {
		t.Fatal("meta should be non-nil")
	}
	wantKeys := map[string]string{
		"page_origin": "/projects/abc",
		"route":       "projects.detail",
		"entity":      "project:abc-123",
		"view":        "verlauf",
		"filter":      "status=open",
		"selection":   "selected phrase",
	}
	for k, want := range wantKeys {
		if got := meta[k]; got != want {
			t.Errorf("meta[%q] = %q; want %q", k, got, want)
		}
	}
}

func TestBuildAichatMeta_TruncatesSelection(t *testing.T) {
	long := strings.Repeat("x", MaxSelectionChars+50)
	req := TurnRequest{Context: &TurnContext{UserSelectionText: long}}
	meta := buildAichatMeta(req)
	got := meta["selection"]
	if !strings.HasSuffix(got, "…") {
		t.Errorf("selection not truncated: ends %q", got[len(got)-10:])
	}
	if strings.Count(got, "x") != MaxSelectionChars {
		t.Errorf("x count = %d; want %d", strings.Count(got, "x"), MaxSelectionChars)
	}
}

func TestCoerceAichatRowsSeen(t *testing.T) {
	cases := []struct {
		in   []string
		want []int
	}{
		{nil, nil},
		{[]string{}, nil},
		{[]string{"3", "5"}, []int{3, 5}},
		{[]string{"3", "abc", "7"}, []int{3, 7}}, // non-numeric dropped
		{[]string{"  12  "}, []int{12}},          // whitespace trimmed
	}
	for _, c := range cases {
		got := coerceAichatRowsSeen(c.in)
		if !intSlicesEqual(got, c.want) {
			t.Errorf("coerceAichatRowsSeen(%v) = %v; want %v", c.in, got, c.want)
		}
	}
}

// =============================================================================
// Primer cache shape
// =============================================================================

func TestPrimerCache_PerSessionIsolation(t *testing.T) {
	s := newAichatService(t, nil, nil)
	s.markPrimed("paliadin:alice")
	if !s.isPrimed("paliadin:alice") {
		t.Error("alice should be primed")
	}
	if s.isPrimed("paliadin:bob") {
		t.Error("bob should NOT be primed (cache cross-leak)")
	}
	s.clearPrimed("paliadin:alice")
	if s.isPrimed("paliadin:alice") {
		t.Error("alice should be cleared")
	}
}

// =============================================================================
// helpers
// =============================================================================

func setHealthResp(out any, ok bool) {
	if hr, isHealth := out.(*aichatHealthResponse); isHealth {
		hr.OK = ok
		hr.ClaudeReachable = ok
		hr.TmuxReachable = ok
	}
}

func setResetResp(out any, ok bool) {
	if rr, isReset := out.(*aichatResetResponse); isReset {
		rr.OK = ok
	}
}

func setTurnResp(out any, body string, paneSpawned bool) {
	if tr, isTurn := out.(*aichatTurnResponse); isTurn {
		tr.Response = body
		tr.PaneSpawned = paneSpawned
	}
}

func intSlicesEqual(a, b []int) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}
