package service

import (
	"context"
	"errors"
	"fmt"
	"testing"
	"time"

	"github.com/orchard9/rdev/internal/domain"
	"github.com/orchard9/rdev/internal/port"
)

// ---------------------------------------------------------------------------
// Mock: SessionRepository
// ---------------------------------------------------------------------------

type mockSessionRepo struct {
	sessions map[string]*domain.Session
	err      error
	nextID   int
}

func (m *mockSessionRepo) Create(ctx context.Context, session *domain.Session) error {
	if m.err != nil {
		return m.err
	}
	m.nextID++
	session.ID = domain.SessionID(fmt.Sprintf("test-session-%d", m.nextID))
	m.sessions[string(session.ID)] = session
	return nil
}

func (m *mockSessionRepo) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
	if m.err != nil {
		return nil, m.err
	}
	s, ok := m.sessions[string(id)]
	if !ok {
		return nil, domain.ErrSessionNotFound
	}
	return s, nil
}

func (m *mockSessionRepo) GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error) {
	if m.err != nil {
		return nil, m.err
	}
	for _, s := range m.sessions {
		if s.ProjectID == projectID && s.Status == domain.SessionStatusActive {
			return s, nil
		}
	}
	return nil, domain.ErrSessionNotFound
}

func (m *mockSessionRepo) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
	if m.err != nil {
		return nil, m.err
	}
	var result []*domain.Session
	for _, s := range m.sessions {
		if s.ProjectID == projectID {
			result = append(result, s)
		}
	}
	return result, nil
}

func (m *mockSessionRepo) SetEnded(ctx context.Context, id domain.SessionID) error {
	if m.err != nil {
		return m.err
	}
	s, ok := m.sessions[string(id)]
	if !ok {
		return domain.ErrSessionNotFound
	}
	s.Status = domain.SessionStatusEnded
	now := time.Now()
	s.EndedAt = &now
	return nil
}

func (m *mockSessionRepo) TouchActivity(ctx context.Context, id domain.SessionID) error {
	if m.err != nil {
		return m.err
	}
	s, ok := m.sessions[string(id)]
	if !ok {
		return domain.ErrSessionNotFound
	}
	s.LastActivityAt = time.Now()
	return nil
}

func (m *mockSessionRepo) CleanupExpired(ctx context.Context) ([]*domain.Session, error) {
	if m.err != nil {
		return nil, m.err
	}
	var expired []*domain.Session
	for _, s := range m.sessions {
		if s.Status == domain.SessionStatusActive && time.Now().After(s.ExpiresAt) {
			s.Status = domain.SessionStatusExpired
			now := time.Now()
			s.EndedAt = &now
			expired = append(expired, s)
		}
	}
	return expired, nil
}

// ---------------------------------------------------------------------------
// Mock: CheckoutRepository
// ---------------------------------------------------------------------------

type mockCheckoutRepo struct {
	checkouts map[string]*domain.Checkout
	err       error
	nextID    int
}

func (m *mockCheckoutRepo) Create(ctx context.Context, checkout *domain.Checkout) error {
	if m.err != nil {
		return m.err
	}
	m.nextID++
	checkout.ID = domain.CheckoutID(fmt.Sprintf("test-checkout-%d", m.nextID))
	m.checkouts[string(checkout.ID)] = checkout
	return nil
}

func (m *mockCheckoutRepo) Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
	if m.err != nil {
		return nil, m.err
	}
	c, ok := m.checkouts[string(id)]
	if !ok {
		return nil, domain.ErrCheckoutNotFound
	}
	return c, nil
}

func (m *mockCheckoutRepo) GetByProjectBranch(ctx context.Context, projectID domain.ProjectID, branch string) (*domain.Checkout, error) {
	if m.err != nil {
		return nil, m.err
	}
	for _, c := range m.checkouts {
		if c.ProjectID == projectID && c.Branch == branch && c.Status == domain.CheckoutStatusActive {
			return c, nil
		}
	}
	return nil, domain.ErrCheckoutNotFound
}

func (m *mockCheckoutRepo) List(ctx context.Context, opts domain.CheckoutListOptions) ([]*domain.Checkout, error) {
	if m.err != nil {
		return nil, m.err
	}
	var result []*domain.Checkout
	for _, c := range m.checkouts {
		if opts.Status != nil && c.Status != *opts.Status {
			continue
		}
		result = append(result, c)
	}
	return result, nil
}

func (m *mockCheckoutRepo) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) {
	if m.err != nil {
		return nil, m.err
	}
	var result []*domain.Checkout
	for _, c := range m.checkouts {
		if c.ProjectID == projectID {
			result = append(result, c)
		}
	}
	return result, nil
}

func (m *mockCheckoutRepo) UpdateStatus(ctx context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error {
	if m.err != nil {
		return m.err
	}
	c, ok := m.checkouts[string(id)]
	if !ok {
		return domain.ErrCheckoutNotFound
	}
	c.Status = status
	return nil
}

func (m *mockCheckoutRepo) SetCheckedIn(ctx context.Context, id domain.CheckoutID, reviewTaskID string) error {
	if m.err != nil {
		return m.err
	}
	c, ok := m.checkouts[string(id)]
	if !ok {
		return domain.ErrCheckoutNotFound
	}
	c.Status = domain.CheckoutStatusCheckedIn
	now := time.Now()
	c.CheckedInAt = &now
	c.ReviewTaskID = reviewTaskID
	return nil
}

func (m *mockCheckoutRepo) SetReviewTask(ctx context.Context, id domain.CheckoutID, taskID string) error {
	if m.err != nil {
		return m.err
	}
	c, ok := m.checkouts[string(id)]
	if !ok {
		return domain.ErrCheckoutNotFound
	}
	c.ReviewTaskID = taskID
	return nil
}

func (m *mockCheckoutRepo) CleanupExpired(ctx context.Context) ([]int64, error) {
	if m.err != nil {
		return nil, m.err
	}
	var tokenIDs []int64
	for _, c := range m.checkouts {
		if c.Status == domain.CheckoutStatusActive && time.Now().After(c.ExpiresAt) {
			c.Status = domain.CheckoutStatusExpired
			tokenIDs = append(tokenIDs, c.GiteaTokenID)
		}
	}
	return tokenIDs, nil
}

// ---------------------------------------------------------------------------
// Mock: GitRepository
// ---------------------------------------------------------------------------

type mockGitRepo struct {
	err           error
	deletedTokens []int64
}

func (m *mockGitRepo) CreateRepo(_ context.Context, _, _ string, _ bool) (*domain.Repo, error) {
	return &domain.Repo{}, m.err
}

func (m *mockGitRepo) DeleteRepo(_ context.Context, _, _ string) error {
	return m.err
}

func (m *mockGitRepo) ListRepos(_ context.Context, _ string) ([]*domain.Repo, error) {
	return nil, m.err
}

func (m *mockGitRepo) GetRepo(_ context.Context, _, _ string) (*domain.Repo, error) {
	return &domain.Repo{}, m.err
}

func (m *mockGitRepo) AddCollaborator(_ context.Context, _, _, _, _ string) error {
	return m.err
}

func (m *mockGitRepo) RemoveCollaborator(_ context.Context, _, _, _ string) error {
	return m.err
}

func (m *mockGitRepo) AddDeployKey(_ context.Context, _, _, _, _ string, _ bool) (*domain.DeployKey, error) {
	return &domain.DeployKey{}, m.err
}

func (m *mockGitRepo) DeleteDeployKey(_ context.Context, _, _ string, _ int64) error {
	return m.err
}

func (m *mockGitRepo) CreateWebhook(_ context.Context, _, _, _, _ string, _ []string) (*domain.RepoWebhook, error) {
	return &domain.RepoWebhook{}, m.err
}

func (m *mockGitRepo) DeleteWebhook(_ context.Context, _, _ string, _ int64) error {
	return m.err
}

func (m *mockGitRepo) ListBranches(_ context.Context, _, _ string) ([]*domain.GitBranch, error) {
	if m.err != nil {
		return nil, m.err
	}
	return []*domain.GitBranch{
		{Name: "main", Protected: true},
		{Name: "develop", Protected: false},
	}, nil
}

func (m *mockGitRepo) CreateBranch(_ context.Context, _, _, branchName, _ string) (*domain.GitBranch, error) {
	if m.err != nil {
		return nil, m.err
	}
	return &domain.GitBranch{Name: branchName}, nil
}

func (m *mockGitRepo) CreateAccessToken(_ context.Context, name string, _ []string, _ *time.Time) (*domain.GitAccessToken, error) {
	if m.err != nil {
		return nil, m.err
	}
	return &domain.GitAccessToken{
		ID:    12345,
		Name:  name,
		Token: "test-token",
	}, nil
}

func (m *mockGitRepo) DeleteAccessToken(_ context.Context, tokenID int64) error {
	m.deletedTokens = append(m.deletedTokens, tokenID)
	return m.err
}

// mockProjectRepo is defined in sdlc_service_test.go (same package).
// Use newMockProjectRepo() from there.

// ---------------------------------------------------------------------------
// Mock: PreviewManager
// ---------------------------------------------------------------------------

type mockPreviewMgr struct {
	previews  map[string]bool
	createErr error
	deleteErr error
}

func (m *mockPreviewMgr) CreatePreview(_ context.Context, opts port.PreviewOptions) error {
	if m.createErr != nil {
		return m.createErr
	}
	m.previews[opts.SessionID] = true
	return nil
}

func (m *mockPreviewMgr) DeletePreview(_ context.Context, sessionID string) error {
	if m.deleteErr != nil {
		return m.deleteErr
	}
	delete(m.previews, sessionID)
	return nil
}

// ---------------------------------------------------------------------------
// Test helper
// ---------------------------------------------------------------------------

func setupTestSessionService() (*SessionService, *mockSessionRepo, *mockPreviewMgr, *mockCheckoutRepo) {
	sessionRepo := &mockSessionRepo{sessions: make(map[string]*domain.Session)}
	checkoutRepo := &mockCheckoutRepo{checkouts: make(map[string]*domain.Checkout)}
	gitRepo := &mockGitRepo{}
	projectRepo := newMockProjectRepo(&domain.Project{
		ID:      "test-project",
		Name:    "test-project",
		PodName: "test-project-0",
		Status:  domain.ProjectStatusRunning,
	})
	previewMgr := &mockPreviewMgr{previews: make(map[string]bool)}

	checkoutSvc := NewCheckoutService(checkoutRepo, gitRepo, projectRepo, CheckoutServiceConfig{
		GiteaURL:      "https://git.threesix.ai",
		DefaultOwner:  "threesix",
		DefaultExpiry: 24 * time.Hour,
	})

	svc := NewSessionService(sessionRepo, checkoutSvc, projectRepo, previewMgr, SessionServiceConfig{
		PreviewDomain: "preview.threesix.ai",
		DefaultExpiry: 24 * time.Hour,
	})

	return svc, sessionRepo, previewMgr, checkoutRepo
}

// ---------------------------------------------------------------------------
// Tests: CreateSession
// ---------------------------------------------------------------------------

func TestSessionService_CreateSession(t *testing.T) {
	t.Run("create_success", func(t *testing.T) {
		svc, sessionRepo, previewMgr, _ := setupTestSessionService()

		result, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "test-project",
			NewBranch: "feat-test",
			FromRef:   "main",
			CreatedBy: "tester",
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		// Verify session ID is assigned.
		if result.Session.ID == "" {
			t.Error("session ID should not be empty")
		}

		// Verify preview URL is set and well-formed.
		if result.Session.PreviewURL == "" {
			t.Error("preview URL should not be empty")
		}
		if len(result.Session.PreviewHost) == 0 {
			t.Error("preview host should not be empty")
		}

		// Verify instructions contain useful info.
		if result.Instructions == "" {
			t.Error("instructions should not be empty")
		}

		// Verify LastActivityAt is set.
		if result.Session.LastActivityAt.IsZero() {
			t.Error("LastActivityAt should be set")
		}

		// Verify session is stored in the repo.
		if len(sessionRepo.sessions) != 1 {
			t.Errorf("session repo count = %d, want 1", len(sessionRepo.sessions))
		}

		// Verify preview was created.
		if len(previewMgr.previews) != 1 {
			t.Errorf("preview count = %d, want 1", len(previewMgr.previews))
		}

		// Verify session status is active.
		if result.Session.Status != domain.SessionStatusActive {
			t.Errorf("session status = %s, want %s", result.Session.Status, domain.SessionStatusActive)
		}

		// Verify branch is returned.
		if result.Branch == "" {
			t.Error("branch should not be empty")
		}

		// Verify authenticated clone URL is returned.
		if result.AuthenticatedCloneURL == "" {
			t.Error("authenticated clone URL should not be empty")
		}
	})

	t.Run("create_project_not_found", func(t *testing.T) {
		svc, _, _, _ := setupTestSessionService()

		_, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "nonexistent",
			NewBranch: "feat-test",
			CreatedBy: "tester",
		})
		if err == nil {
			t.Fatal("expected error for nonexistent project")
		}
		if !errors.Is(err, domain.ErrProjectNotFound) {
			t.Errorf("error = %v, want wrapping %v", err, domain.ErrProjectNotFound)
		}
	})

	t.Run("create_project_not_running", func(t *testing.T) {
		svc, _, _, _ := setupTestSessionService()

		// Add a project with no PodName (not running).
		svc.projectRepo.(*mockProjectRepo).projects[domain.ProjectID("stopped-project")] = &domain.Project{
			ID:      "stopped-project",
			Name:    "stopped-project",
			PodName: "",
			Status:  domain.ProjectStatusPending,
		}

		_, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "stopped-project",
			NewBranch: "feat-test",
			CreatedBy: "tester",
		})
		if err == nil {
			t.Fatal("expected error for project not running")
		}
		if !errors.Is(err, domain.ErrProjectNotRunning) {
			t.Errorf("error = %v, want %v", err, domain.ErrProjectNotRunning)
		}
	})

	t.Run("create_active_session_exists", func(t *testing.T) {
		svc, sessionRepo, _, _ := setupTestSessionService()

		// Seed an active, non-expired session.
		sessionRepo.sessions["existing"] = &domain.Session{
			ID:             "existing",
			ProjectID:      "test-project",
			Status:         domain.SessionStatusActive,
			ExpiresAt:      time.Now().Add(1 * time.Hour),
			LastActivityAt: time.Now(),
		}

		_, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "test-project",
			NewBranch: "feat-test",
			CreatedBy: "tester",
		})
		if err == nil {
			t.Fatal("expected error for existing active session")
		}
		if !errors.Is(err, domain.ErrSessionExists) {
			t.Errorf("error = %v, want %v", err, domain.ErrSessionExists)
		}
	})

	t.Run("create_preview_failure_rollback", func(t *testing.T) {
		svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService()

		previewMgr.createErr = fmt.Errorf("k8s ingress failed")

		_, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "test-project",
			NewBranch: "feat-test",
			CreatedBy: "tester",
		})
		if err == nil {
			t.Fatal("expected error for preview creation failure")
		}

		// Verify session was rolled back (marked as ended).
		for _, s := range sessionRepo.sessions {
			if s.Status == domain.SessionStatusActive {
				t.Error("session should have been marked ended during rollback")
			}
		}

		// Verify checkout was revoked during rollback.
		for _, c := range checkoutRepo.checkouts {
			if c.Status == domain.CheckoutStatusActive {
				t.Error("checkout should have been revoked during rollback")
			}
		}
	})

	t.Run("create_db_failure_rollback", func(t *testing.T) {
		svc, sessionRepo, _, checkoutRepo := setupTestSessionService()

		sessionRepo.err = fmt.Errorf("db error")

		_, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "test-project",
			NewBranch: "feat-test",
			CreatedBy: "tester",
		})
		if err == nil {
			t.Fatal("expected error for DB failure")
		}

		// The session create failed, so the checkout should have been revoked.
		// The checkout was created before the session record, so it exists.
		for _, c := range checkoutRepo.checkouts {
			if c.Status == domain.CheckoutStatusActive {
				t.Error("checkout should have been revoked after session DB failure")
			}
		}
	})

	t.Run("create_replaces_expired_session", func(t *testing.T) {
		svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService()

		// Seed an expired active session with a checkout.
		expiredCheckoutID := domain.CheckoutID("expired-checkout")
		checkoutRepo.checkouts["expired-checkout"] = &domain.Checkout{
			ID:           expiredCheckoutID,
			ProjectID:    "test-project",
			Branch:       "old-branch",
			GiteaTokenID: 99999,
			Status:       domain.CheckoutStatusActive,
			ExpiresAt:    time.Now().Add(1 * time.Hour), // checkout not expired
		}

		sessionRepo.sessions["expired-session"] = &domain.Session{
			ID:             "expired-session",
			ProjectID:      "test-project",
			CheckoutID:     expiredCheckoutID,
			Status:         domain.SessionStatusActive,
			ExpiresAt:      time.Now().Add(-1 * time.Hour), // expired
			LastActivityAt: time.Now().Add(-2 * time.Hour),
		}
		previewMgr.previews["expired-session"] = true

		result, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "test-project",
			NewBranch: "feat-new",
			CreatedBy: "tester",
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		// The new session should have been created.
		if result.Session.ID == "" {
			t.Error("new session ID should not be empty")
		}

		// The expired session should be ended.
		expiredSession := sessionRepo.sessions["expired-session"]
		if expiredSession.Status != domain.SessionStatusEnded {
			t.Errorf("expired session status = %s, want %s", expiredSession.Status, domain.SessionStatusEnded)
		}

		// The expired session's preview should have been deleted.
		if previewMgr.previews["expired-session"] {
			t.Error("expired session preview should have been deleted")
		}
	})
}

// ---------------------------------------------------------------------------
// Tests: EndSession
// ---------------------------------------------------------------------------

func TestSessionService_EndSession(t *testing.T) {
	t.Run("end_success", func(t *testing.T) {
		svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService()

		// Seed an active checkout.
		checkoutRepo.checkouts["checkout-1"] = &domain.Checkout{
			ID:           "checkout-1",
			ProjectID:    "test-project",
			Branch:       "feat-test",
			GiteaTokenID: 11111,
			Status:       domain.CheckoutStatusActive,
			ExpiresAt:    time.Now().Add(1 * time.Hour),
		}

		// Seed an active session.
		sessionRepo.sessions["session-1"] = &domain.Session{
			ID:             "session-1",
			ProjectID:      "test-project",
			CheckoutID:     "checkout-1",
			PodName:        "test-project-0",
			PreviewURL:     "https://abc.preview.threesix.ai",
			PreviewHost:    "abc.preview.threesix.ai",
			Status:         domain.SessionStatusActive,
			ExpiresAt:      time.Now().Add(1 * time.Hour),
			LastActivityAt: time.Now(),
		}
		previewMgr.previews["session-1"] = true

		result, err := svc.EndSession(context.Background(), EndSessionRequest{
			SessionID: "session-1",
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		if result.Status != domain.SessionStatusEnded {
			t.Errorf("result status = %s, want %s", result.Status, domain.SessionStatusEnded)
		}
		if result.SessionID != "session-1" {
			t.Errorf("session ID = %s, want session-1", result.SessionID)
		}

		// Verify session is marked ended in repo.
		session := sessionRepo.sessions["session-1"]
		if session.Status != domain.SessionStatusEnded {
			t.Errorf("session repo status = %s, want %s", session.Status, domain.SessionStatusEnded)
		}

		// Verify preview was deleted.
		if previewMgr.previews["session-1"] {
			t.Error("preview should have been deleted")
		}

		// Verify checkout was checked in (EndSession calls Checkin which marks checked_in).
		checkout := checkoutRepo.checkouts["checkout-1"]
		if checkout.Status != domain.CheckoutStatusCheckedIn {
			t.Errorf("checkout status = %s, want %s", checkout.Status, domain.CheckoutStatusCheckedIn)
		}
	})

	t.Run("end_not_active", func(t *testing.T) {
		svc, sessionRepo, _, _ := setupTestSessionService()

		// Seed an ended session.
		now := time.Now()
		sessionRepo.sessions["ended-session"] = &domain.Session{
			ID:        "ended-session",
			ProjectID: "test-project",
			Status:    domain.SessionStatusEnded,
			EndedAt:   &now,
		}

		_, err := svc.EndSession(context.Background(), EndSessionRequest{
			SessionID: "ended-session",
		})
		if err == nil {
			t.Fatal("expected error for ended session")
		}
		if !errors.Is(err, domain.ErrSessionNotActive) {
			t.Errorf("error = %v, want %v", err, domain.ErrSessionNotActive)
		}
	})

	t.Run("end_not_found", func(t *testing.T) {
		svc, _, _, _ := setupTestSessionService()

		_, err := svc.EndSession(context.Background(), EndSessionRequest{
			SessionID: "nonexistent",
		})
		if err == nil {
			t.Fatal("expected error for nonexistent session")
		}
		if !errors.Is(err, domain.ErrSessionNotFound) {
			t.Errorf("error = %v, want %v", err, domain.ErrSessionNotFound)
		}
	})
}

// ---------------------------------------------------------------------------
// Tests: ForceEnd
// ---------------------------------------------------------------------------

func TestSessionService_ForceEnd(t *testing.T) {
	t.Run("force_end_success", func(t *testing.T) {
		svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService()

		// Seed an active checkout.
		checkoutRepo.checkouts["checkout-f"] = &domain.Checkout{
			ID:           "checkout-f",
			ProjectID:    "test-project",
			Branch:       "feat-force",
			GiteaTokenID: 22222,
			Status:       domain.CheckoutStatusActive,
			ExpiresAt:    time.Now().Add(1 * time.Hour),
		}

		// Seed an active session.
		sessionRepo.sessions["session-f"] = &domain.Session{
			ID:             "session-f",
			ProjectID:      "test-project",
			CheckoutID:     "checkout-f",
			PodName:        "test-project-0",
			Status:         domain.SessionStatusActive,
			ExpiresAt:      time.Now().Add(1 * time.Hour),
			LastActivityAt: time.Now(),
		}
		previewMgr.previews["session-f"] = true

		err := svc.ForceEnd(context.Background(), "session-f")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		// Verify session is ended.
		session := sessionRepo.sessions["session-f"]
		if session.Status != domain.SessionStatusEnded {
			t.Errorf("session status = %s, want %s", session.Status, domain.SessionStatusEnded)
		}

		// Verify preview was deleted.
		if previewMgr.previews["session-f"] {
			t.Error("preview should have been deleted")
		}

		// Verify checkout was revoked (ForceEnd calls Revoke, not Checkin).
		checkout := checkoutRepo.checkouts["checkout-f"]
		if checkout.Status != domain.CheckoutStatusRevoked {
			t.Errorf("checkout status = %s, want %s", checkout.Status, domain.CheckoutStatusRevoked)
		}
	})

	t.Run("force_end_not_active", func(t *testing.T) {
		svc, sessionRepo, _, _ := setupTestSessionService()

		// Seed an ended session.
		now := time.Now()
		sessionRepo.sessions["ended-f"] = &domain.Session{
			ID:        "ended-f",
			ProjectID: "test-project",
			Status:    domain.SessionStatusEnded,
			EndedAt:   &now,
		}

		err := svc.ForceEnd(context.Background(), "ended-f")
		if err == nil {
			t.Fatal("expected error for ended session")
		}
		if !errors.Is(err, domain.ErrSessionNotActive) {
			t.Errorf("error = %v, want %v", err, domain.ErrSessionNotActive)
		}
	})

	t.Run("force_end_not_found", func(t *testing.T) {
		svc, _, _, _ := setupTestSessionService()

		err := svc.ForceEnd(context.Background(), "nonexistent")
		if err == nil {
			t.Fatal("expected error for nonexistent session")
		}
		if !errors.Is(err, domain.ErrSessionNotFound) {
			t.Errorf("error = %v, want %v", err, domain.ErrSessionNotFound)
		}
	})
}

// ---------------------------------------------------------------------------
// Tests: TouchActivity
// ---------------------------------------------------------------------------

func TestSessionService_TouchActivity(t *testing.T) {
	t.Run("touch_success", func(t *testing.T) {
		svc, sessionRepo, _, _ := setupTestSessionService()

		originalTime := time.Now().Add(-10 * time.Minute)
		sessionRepo.sessions["session-t"] = &domain.Session{
			ID:             "session-t",
			ProjectID:      "test-project",
			Status:         domain.SessionStatusActive,
			ExpiresAt:      time.Now().Add(1 * time.Hour),
			LastActivityAt: originalTime,
		}

		err := svc.TouchActivity(context.Background(), "session-t")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		session := sessionRepo.sessions["session-t"]
		if !session.LastActivityAt.After(originalTime) {
			t.Error("LastActivityAt should have been updated to a more recent time")
		}
	})

	t.Run("touch_not_found", func(t *testing.T) {
		svc, _, _, _ := setupTestSessionService()

		err := svc.TouchActivity(context.Background(), "nonexistent")
		if err == nil {
			t.Fatal("expected error for nonexistent session")
		}
		if !errors.Is(err, domain.ErrSessionNotFound) {
			t.Errorf("error = %v, want %v", err, domain.ErrSessionNotFound)
		}
	})

	t.Run("touch_repo_error", func(t *testing.T) {
		svc, sessionRepo, _, _ := setupTestSessionService()

		sessionRepo.err = fmt.Errorf("database unavailable")

		err := svc.TouchActivity(context.Background(), "any-id")
		if err == nil {
			t.Fatal("expected error from repo")
		}
	})
}

// ---------------------------------------------------------------------------
// Tests: CleanupExpired
// ---------------------------------------------------------------------------

func TestSessionService_CleanupExpired(t *testing.T) {
	t.Run("cleanup_expired", func(t *testing.T) {
		svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService()

		// Seed an expired session with a checkout and preview.
		checkoutRepo.checkouts["checkout-exp"] = &domain.Checkout{
			ID:           "checkout-exp",
			ProjectID:    "test-project",
			Branch:       "feat-expired",
			GiteaTokenID: 33333,
			Status:       domain.CheckoutStatusActive,
			ExpiresAt:    time.Now().Add(1 * time.Hour), // checkout not expired
		}

		sessionRepo.sessions["session-exp"] = &domain.Session{
			ID:             "session-exp",
			ProjectID:      "test-project",
			CheckoutID:     "checkout-exp",
			PodName:        "test-project-0",
			Status:         domain.SessionStatusActive,
			ExpiresAt:      time.Now().Add(-1 * time.Hour), // expired 1 hour ago
			LastActivityAt: time.Now().Add(-2 * time.Hour),
		}
		previewMgr.previews["session-exp"] = true

		count, err := svc.CleanupExpired(context.Background())
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if count != 1 {
			t.Errorf("cleanup count = %d, want 1", count)
		}

		// Verify preview was deleted.
		if previewMgr.previews["session-exp"] {
			t.Error("expired session preview should have been deleted")
		}

		// Verify checkout was revoked.
		checkout := checkoutRepo.checkouts["checkout-exp"]
		if checkout.Status != domain.CheckoutStatusRevoked {
			t.Errorf("checkout status = %s, want %s", checkout.Status, domain.CheckoutStatusRevoked)
		}

		// Verify session was marked expired.
		session := sessionRepo.sessions["session-exp"]
		if session.Status != domain.SessionStatusExpired {
			t.Errorf("session status = %s, want %s", session.Status, domain.SessionStatusExpired)
		}
	})

	t.Run("cleanup_no_expired", func(t *testing.T) {
		svc, sessionRepo, _, _ := setupTestSessionService()

		// Seed an active, non-expired session.
		sessionRepo.sessions["session-active"] = &domain.Session{
			ID:             "session-active",
			ProjectID:      "test-project",
			Status:         domain.SessionStatusActive,
			ExpiresAt:      time.Now().Add(1 * time.Hour),
			LastActivityAt: time.Now(),
		}

		count, err := svc.CleanupExpired(context.Background())
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if count != 0 {
			t.Errorf("cleanup count = %d, want 0", count)
		}
	})

	t.Run("cleanup_repo_error", func(t *testing.T) {
		svc, sessionRepo, _, _ := setupTestSessionService()

		sessionRepo.err = fmt.Errorf("database error")

		_, err := svc.CleanupExpired(context.Background())
		if err == nil {
			t.Fatal("expected error from repo")
		}
	})

	t.Run("cleanup_multiple_expired", func(t *testing.T) {
		svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService()

		// Seed two expired sessions.
		for i, name := range []string{"multi-1", "multi-2"} {
			coID := domain.CheckoutID(fmt.Sprintf("co-multi-%d", i))
			checkoutRepo.checkouts[string(coID)] = &domain.Checkout{
				ID:           coID,
				ProjectID:    "test-project",
				Branch:       fmt.Sprintf("feat-%s", name),
				GiteaTokenID: int64(40000 + i),
				Status:       domain.CheckoutStatusActive,
				ExpiresAt:    time.Now().Add(1 * time.Hour),
			}

			sessionRepo.sessions[name] = &domain.Session{
				ID:             domain.SessionID(name),
				ProjectID:      "test-project",
				CheckoutID:     coID,
				PodName:        "test-project-0",
				Status:         domain.SessionStatusActive,
				ExpiresAt:      time.Now().Add(-1 * time.Hour),
				LastActivityAt: time.Now().Add(-2 * time.Hour),
			}
			previewMgr.previews[name] = true
		}

		count, err := svc.CleanupExpired(context.Background())
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if count != 2 {
			t.Errorf("cleanup count = %d, want 2", count)
		}

		// All previews should be deleted.
		if len(previewMgr.previews) != 0 {
			t.Errorf("remaining previews = %d, want 0", len(previewMgr.previews))
		}
	})
}

// ---------------------------------------------------------------------------
// Tests: TeardownSession (indirect via CreateSession with expired existing)
// ---------------------------------------------------------------------------

func TestSessionService_TeardownSession(t *testing.T) {
	t.Run("teardown_revokes_token", func(t *testing.T) {
		svc, sessionRepo, previewMgr, checkoutRepo := setupTestSessionService()

		// Create the first session normally.
		result1, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "test-project",
			NewBranch: "feat-first",
			FromRef:   "main",
			CreatedBy: "tester",
		})
		if err != nil {
			t.Fatalf("failed to create first session: %v", err)
		}

		firstSessionID := result1.Session.ID
		firstCheckoutID := result1.Session.CheckoutID

		// Verify the first session is active.
		if result1.Session.Status != domain.SessionStatusActive {
			t.Fatalf("first session status = %s, want active", result1.Session.Status)
		}

		// Now expire the first session by manipulating its ExpiresAt.
		sessionRepo.sessions[string(firstSessionID)].ExpiresAt = time.Now().Add(-1 * time.Hour)

		// Create a second session for the same project. This should trigger
		// teardown of the expired first session.
		result2, err := svc.CreateSession(context.Background(), CreateSessionRequest{
			ProjectID: "test-project",
			NewBranch: "feat-second",
			FromRef:   "main",
			CreatedBy: "tester",
		})
		if err != nil {
			t.Fatalf("failed to create second session: %v", err)
		}

		// Verify the second session was created.
		if result2.Session.ID == "" {
			t.Error("second session ID should not be empty")
		}

		// Verify the first session was ended (torn down).
		firstSession := sessionRepo.sessions[string(firstSessionID)]
		if firstSession.Status != domain.SessionStatusEnded {
			t.Errorf("first session status = %s, want %s", firstSession.Status, domain.SessionStatusEnded)
		}

		// Verify the first session's preview was deleted.
		if previewMgr.previews[string(firstSessionID)] {
			t.Error("first session preview should have been deleted during teardown")
		}

		// Verify the first session's checkout was revoked.
		firstCheckout := checkoutRepo.checkouts[string(firstCheckoutID)]
		if firstCheckout.Status != domain.CheckoutStatusRevoked {
			t.Errorf("first checkout status = %s, want %s", firstCheckout.Status, domain.CheckoutStatusRevoked)
		}

		// Verify the second session is active.
		if result2.Session.Status != domain.SessionStatusActive {
			t.Errorf("second session status = %s, want active", result2.Session.Status)
		}
	})
}
