package changerequest

import (
	"context"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/yan1h/agent-coding-workflow/internal/audit"
	"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
	"github.com/yan1h/agent-coding-workflow/internal/infra/vcs"
)

// ===== fakes =====

type fakeRepo struct {
	items   map[uuid.UUID]*ChangeRequest
	nextNum int
}

func newFakeRepo() *fakeRepo { return &fakeRepo{items: map[uuid.UUID]*ChangeRequest{}, nextNum: 1} }

func (f *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (*ChangeRequest, error) {
	cr, ok := f.items[id]
	if !ok {
		return nil, errs.New(errs.CodeNotFound, "not found")
	}
	cp := *cr
	return &cp, nil
}
func (f *fakeRepo) GetByNumber(_ context.Context, _ string, number int) (*ChangeRequest, error) {
	for _, cr := range f.items {
		if cr.Number == number {
			cp := *cr
			return &cp, nil
		}
	}
	return nil, errs.New(errs.CodeNotFound, "not found")
}
func (f *fakeRepo) ListByWorkspace(_ context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) {
	var out []*ChangeRequest
	for _, cr := range f.items {
		if cr.WorkspaceID == wsID {
			cp := *cr
			out = append(out, &cp)
		}
	}
	return out, nil
}
func (f *fakeRepo) ListByProject(_ context.Context, pid uuid.UUID) ([]*ChangeRequest, error) {
	var out []*ChangeRequest
	for _, cr := range f.items {
		if cr.ProjectID == pid {
			cp := *cr
			out = append(out, &cp)
		}
	}
	return out, nil
}
func (f *fakeRepo) UpdateReview(_ context.Context, id uuid.UUID, v Verdict, by uuid.UUID) (*ChangeRequest, error) {
	cr := f.items[id]
	cr.ReviewVerdict = v
	cr.ReviewedBy = &by
	now := time.Now()
	cr.ReviewedAt = &now
	cp := *cr
	return &cp, nil
}
func (f *fakeRepo) UpdateState(_ context.Context, id uuid.UUID, st State, sha string, at *time.Time) (*ChangeRequest, error) {
	cr := f.items[id]
	cr.State = st
	cr.MergeCommitSHA = sha
	cr.MergedAt = at
	cp := *cr
	return &cp, nil
}
func (f *fakeRepo) UpdateCI(_ context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) {
	cr := f.items[id]
	cr.CIState = ci
	cr.State = st
	cp := *cr
	return &cp, nil
}
func (f *fakeRepo) InTx(ctx context.Context, fn func(Tx) error) error {
	return fn(&fakeTx{r: f})
}

type fakeTx struct{ r *fakeRepo }

func (t *fakeTx) NextNumber(_ context.Context, _ uuid.UUID) (int, error) {
	n := t.r.nextNum
	t.r.nextNum++
	return n, nil
}
func (t *fakeTx) Create(_ context.Context, cr *ChangeRequest) (*ChangeRequest, error) {
	cp := *cr
	// mirror the DB INSERT defaults baked into the query (state/verdict).
	cp.State = StateOpen
	cp.ReviewVerdict = VerdictPending
	if cp.Provider == "" {
		cp.Provider = "gitea"
	}
	t.r.items[cr.ID] = &cp
	out := cp
	return &out, nil
}

type fakeProvider struct {
	createPR    *vcs.PullRequest
	getPR       *vcs.PullRequest
	mergeSHA    string
	mergeErr    error
	mergeCalled bool
}

func (p *fakeProvider) CreatePR(_ context.Context, _ vcs.CreatePRInput) (*vcs.PullRequest, error) {
	if p.createPR != nil {
		return p.createPR, nil
	}
	return &vcs.PullRequest{Number: 1, State: "open", HTMLURL: "http://h/pr/1", CIState: "unknown"}, nil
}
func (p *fakeProvider) GetPR(_ context.Context, _ vcs.RepoRef, _ int64) (*vcs.PullRequest, error) {
	if p.getPR != nil {
		return p.getPR, nil
	}
	return &vcs.PullRequest{Number: 1, State: "open", CIState: "success"}, nil
}
func (p *fakeProvider) Comment(_ context.Context, _ vcs.RepoRef, _ int64, _ string) error { return nil }
func (p *fakeProvider) Merge(_ context.Context, _ vcs.RepoRef, _ int64, _ vcs.MergeInput) (string, error) {
	p.mergeCalled = true
	if p.mergeErr != nil {
		return "", p.mergeErr
	}
	if p.mergeSHA == "" {
		return "merged-sha", nil
	}
	return p.mergeSHA, nil
}
func (p *fakeProvider) ListIssues(_ context.Context, _ vcs.RepoRef, _ vcs.IssueListOptions) ([]vcs.Issue, error) {
	return nil, nil
}

type fakeWS struct {
	meta WorkspaceMeta
	err  error
}

func (f *fakeWS) GetWorkspaceMeta(_ context.Context, _ uuid.UUID) (WorkspaceMeta, error) {
	return f.meta, f.err
}

type fakePA struct {
	canRead, canWrite bool
}

func (f *fakePA) ResolveByID(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, bool, error) {
	return f.canRead, f.canWrite, nil
}

type nopRec struct{}

func (nopRec) Record(_ context.Context, _ audit.Entry) error { return nil }

var (
	tProj = uuid.New()
	tWS   = uuid.New()
	tUser = uuid.New()
)

func newSvc(t *testing.T, prov *fakeProvider, pa *fakePA, cfg Config) (*service, *fakeRepo) {
	t.Helper()
	repo := newFakeRepo()
	ws := &fakeWS{meta: WorkspaceMeta{ProjectID: tProj, GitRemoteURL: "https://git.jerryyan.net/owner/repo.git", DefaultBranch: "main"}}
	svc := NewService(repo, ws, nopRec{}, prov, pa, cfg).(*service)
	return svc, repo
}

func caller() Caller { return Caller{UserID: tUser} }

// ===== tests =====

func TestCreate_PersistsPending(t *testing.T) {
	prov := &fakeProvider{createPR: &vcs.PullRequest{Number: 7, State: "open", HTMLURL: "http://h/7", CIState: "pending"}}
	svc, _ := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})

	cr, err := svc.Create(context.Background(), caller(), tWS, CreateInput{
		SourceBranch: "feature", Title: "T",
	})
	require.NoError(t, err)
	assert.Equal(t, VerdictPending, cr.ReviewVerdict)
	assert.Equal(t, StateOpen, cr.State)
	assert.Equal(t, "main", cr.TargetBranch)
	require.NotNil(t, cr.ExternalID)
	assert.Equal(t, int64(7), *cr.ExternalID)
	assert.Equal(t, 1, cr.Number)
	assert.Equal(t, CIPending, cr.CIState)
}

func TestCreate_RequiresWrite(t *testing.T) {
	svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"})
	_, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "f", Title: "T"})
	require.Error(t, err)
	ae, _ := errs.As(err)
	assert.Equal(t, errs.CodeForbidden, ae.Code)
}

func TestCreate_SameBranchRejected(t *testing.T) {
	svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
	_, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "main", Title: "T"})
	require.Error(t, err)
	ae, _ := errs.As(err)
	assert.Equal(t, errs.CodeInvalidInput, ae.Code)
}

// seed inserts an open, approved-able CR into the repo for merge tests.
func seedCR(repo *fakeRepo, verdict Verdict, ci CIState) *ChangeRequest {
	id := uuid.New()
	ext := int64(7)
	cr := &ChangeRequest{
		ID: id, ProjectID: tProj, WorkspaceID: tWS, Number: 1,
		Title: "T", SourceBranch: "feature", TargetBranch: "main",
		State: StateOpen, ReviewVerdict: verdict, CIState: ci,
		Provider: "gitea", ExternalID: &ext, CreatedBy: tUser,
	}
	repo.items[id] = cr
	return cr
}

func TestMerge_DeniedWhenNotApproved(t *testing.T) {
	for _, v := range []Verdict{VerdictPending, VerdictChangesRequested, VerdictRejected} {
		prov := &fakeProvider{}
		svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
		cr := seedCR(repo, v, CISuccess)

		_, err := svc.Merge(context.Background(), caller(), cr.ID, "")
		require.Error(t, err, v)
		ae, _ := errs.As(err)
		assert.Equal(t, errs.CodeFailedPrecondition, ae.Code, v)
		assert.False(t, prov.mergeCalled, "provider.Merge must NOT be called when verdict=%s", v)
	}
}

func TestMerge_DeniedWhenCINotPassing(t *testing.T) {
	prov := &fakeProvider{}
	svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
	cr := seedCR(repo, VerdictApproved, CIFailure)

	_, err := svc.Merge(context.Background(), caller(), cr.ID, "")
	require.Error(t, err)
	ae, _ := errs.As(err)
	assert.Equal(t, errs.CodeFailedPrecondition, ae.Code)
	assert.False(t, prov.mergeCalled)
}

func TestMerge_AdminBypassesCI(t *testing.T) {
	prov := &fakeProvider{mergeSHA: "sha-x"}
	svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
	cr := seedCR(repo, VerdictApproved, CIFailure)

	out, err := svc.Merge(context.Background(), Caller{UserID: tUser, IsAdmin: true}, cr.ID, "")
	require.NoError(t, err)
	assert.True(t, prov.mergeCalled)
	assert.Equal(t, StateMerged, out.State)
	assert.Equal(t, "sha-x", out.MergeCommitSHA)
}

func TestMerge_AllowedWhenApproved(t *testing.T) {
	prov := &fakeProvider{mergeSHA: "sha-y"}
	svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
	cr := seedCR(repo, VerdictApproved, CISuccess)

	out, err := svc.Merge(context.Background(), caller(), cr.ID, "squash")
	require.NoError(t, err)
	assert.True(t, prov.mergeCalled)
	assert.Equal(t, StateMerged, out.State)
	assert.NotNil(t, out.MergedAt)
	assert.Equal(t, "sha-y", out.MergeCommitSHA)
}

func TestReview_SetsReviewer(t *testing.T) {
	svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
	cr := seedCR(repo, VerdictPending, CIUnknown)

	out, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "lgtm")
	require.NoError(t, err)
	assert.Equal(t, VerdictApproved, out.ReviewVerdict)
	require.NotNil(t, out.ReviewedBy)
	assert.Equal(t, tUser, *out.ReviewedBy)
}

func TestReview_RequiresWrite(t *testing.T) {
	svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"})
	cr := seedCR(repo, VerdictPending, CIUnknown)
	_, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "")
	require.Error(t, err)
	ae, _ := errs.As(err)
	assert.Equal(t, errs.CodeForbidden, ae.Code)
}

func TestSyncStatus_RefreshesCIAndState(t *testing.T) {
	prov := &fakeProvider{getPR: &vcs.PullRequest{Number: 7, State: "closed", Merged: true, CIState: "success", MergeCommitSHA: "z"}}
	svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
	cr := seedCR(repo, VerdictApproved, CIUnknown)

	out, err := svc.SyncStatus(context.Background(), caller(), cr.ID)
	require.NoError(t, err)
	assert.Equal(t, CISuccess, out.CIState)
	assert.Equal(t, StateMerged, out.State)
}
