package storage

import (
	"context"
	"errors"
	"strings"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/nomadcode/nomadcode-core/internal/db"
)

// identityRouteResult is the row/err a routed QueryRow should produce.
type identityRouteResult struct {
	row db.RoadmapSyncIdentity
	err error
}

// identityMockDBTX routes QueryRow calls by a substring found in the SQL so a
// single mock can answer the by-path lookup, the by-work-item lookup, and the
// upsert with different rows.
type identityMockDBTX struct {
	byPath   identityRouteResult
	byWork   identityRouteResult
	upsert   identityRouteResult
	queryLog []string
}

func (m *identityMockDBTX) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
	return pgconn.NewCommandTag("OK"), nil
}

func (m *identityMockDBTX) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
	return nil, errors.New("unexpected Query call")
}

func (m *identityMockDBTX) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
	m.queryLog = append(m.queryLog, sql)
	switch {
	case strings.Contains(sql, "INSERT INTO roadmap_sync_identities"):
		return &identityMockRow{result: m.upsert}
	case strings.Contains(sql, "WHERE roadmap_milestone_path = $1"):
		return &identityMockRow{result: m.byPath}
	case strings.Contains(sql, "WHERE provider = $1"):
		return &identityMockRow{result: m.byWork}
	default:
		return &identityMockRow{result: identityRouteResult{err: errors.New("unrouted sql: " + sql)}}
	}
}

type identityMockRow struct {
	result identityRouteResult
}

func (r *identityMockRow) Scan(dest ...interface{}) error {
	if r.result.err != nil {
		return r.result.err
	}
	scanRoadmapSyncIdentity(r.result.row, dest)
	return nil
}

// scanRoadmapSyncIdentity fills scan destinations in the column order used by
// the generated roadmap_sync_identities queries.
func scanRoadmapSyncIdentity(row db.RoadmapSyncIdentity, dest []interface{}) {
	if len(dest) < 13 {
		return
	}
	assign(dest[0], row.ID)
	assign(dest[1], row.Shape)
	assign(dest[2], row.RoadmapMilestonePath)
	assign(dest[3], row.RoadmapItemID)
	assign(dest[4], row.Provider)
	assign(dest[5], row.Tenant)
	assign(dest[6], row.Project)
	assign(dest[7], row.WorkItemID)
	assign(dest[8], row.ParentWorkItemID)
	if p, ok := dest[9].(**string); ok {
		*p = row.ProviderRevision
	}
	if p, ok := dest[10].(**string); ok {
		*p = row.RoadmapRevision
	}
	if p, ok := dest[11].(*time.Time); ok {
		*p = row.CreatedAt
	}
	if p, ok := dest[12].(*time.Time); ok {
		*p = row.UpdatedAt
	}
}

func assign[T any](dst interface{}, v T) {
	if p, ok := dst.(*T); ok {
		*p = v
	}
}

func newIdentityStore(m *identityMockDBTX) *Store {
	return NewStoreWithQueries(db.New(m))
}

func sampleUpsertParams() db.UpsertRoadmapSyncIdentityByWorkItemParams {
	return db.UpsertRoadmapSyncIdentityByWorkItemParams{
		Shape:                "milestone",
		RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
		Provider:             "plane",
		Tenant:               "general",
		Project:              "NOMAD",
		WorkItemID:           "NOMAD-13",
	}
}

// ---- UpsertRoadmapSyncIdentity ----

func TestUpsertRoadmapSyncIdentity_ByWorkItemReturnsRow(t *testing.T) {
	args := sampleUpsertParams()
	m := &identityMockDBTX{
		upsert: identityRouteResult{row: db.RoadmapSyncIdentity{
			ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath,
			Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
		}},
	}
	store := newIdentityStore(m)

	row, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if row.ID != 7 || row.WorkItemID != "NOMAD-13" || row.RoadmapMilestonePath != args.RoadmapMilestonePath {
		t.Errorf("identity not preserved: %+v", row)
	}
	if !sqlLogContains(m.queryLog, "ON CONFLICT (provider, tenant, project, work_item_id)") {
		t.Errorf("expected upsert query shape, log=%v", m.queryLog)
	}
	// The conflict path must keep the stored Milestone path and only update when
	// the incoming path already matches, so the rebind race is closed in SQL.
	if !sqlLogContains(m.queryLog, "WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path") {
		t.Errorf("expected conditional path guard in DO UPDATE, log=%v", m.queryLog)
	}
	if sqlLogContains(m.queryLog, "roadmap_milestone_path = EXCLUDED.roadmap_milestone_path,") {
		t.Errorf("DO UPDATE must not overwrite roadmap_milestone_path, log=%v", m.queryLog)
	}
}

func TestUpsertRoadmapSyncIdentity_SamePathSameWorkItemReuses(t *testing.T) {
	args := sampleUpsertParams()
	existing := db.RoadmapSyncIdentity{
		ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath,
		Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
	}
	m := &identityMockDBTX{upsert: identityRouteResult{row: existing}}
	store := newIdentityStore(m)

	row, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if row.ID != 7 {
		t.Errorf("expected reuse of row 7, got %+v", row)
	}
}

// Same provider work item arriving with a different Milestone path: the
// conditional DO UPDATE matches no row, RETURNING is empty, and the store maps
// pgx.ErrNoRows to a typed conflict instead of rebinding the ticket.
func TestUpsertRoadmapSyncIdentity_WorkItemRebindToDifferentPathConflicts(t *testing.T) {
	args := sampleUpsertParams()
	m := &identityMockDBTX{upsert: identityRouteResult{err: pgx.ErrNoRows}}
	store := newIdentityStore(m)

	_, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
	if !errors.Is(err, ErrRoadmapSyncIdentityConflict) {
		t.Fatalf("expected conflict, got %v", err)
	}
}

// A different provider work item re-using an existing Milestone path trips the
// path unique index (SQLSTATE 23505); the store maps it to a typed conflict
// rather than surfacing a raw pg error.
func TestUpsertRoadmapSyncIdentity_PathUniqueViolationConflicts(t *testing.T) {
	args := sampleUpsertParams()
	m := &identityMockDBTX{upsert: identityRouteResult{err: &pgconn.PgError{
		Code:           pgUniqueViolation,
		ConstraintName: "ux_roadmap_sync_identities_milestone_path",
	}}}
	store := newIdentityStore(m)

	_, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
	if !errors.Is(err, ErrRoadmapSyncIdentityConflict) {
		t.Fatalf("expected conflict, got %v", err)
	}
}

// A non-conflict DB error must propagate unchanged, not be swallowed as a
// conflict.
func TestUpsertRoadmapSyncIdentity_OtherDBErrorPropagates(t *testing.T) {
	args := sampleUpsertParams()
	sentinel := errors.New("connection reset")
	m := &identityMockDBTX{upsert: identityRouteResult{err: sentinel}}
	store := newIdentityStore(m)

	_, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
	if !errors.Is(err, sentinel) {
		t.Fatalf("expected sentinel error, got %v", err)
	}
	if errors.Is(err, ErrRoadmapSyncIdentityConflict) {
		t.Error("generic DB error must not be reported as conflict")
	}
}

// ---- Get lookups ----

func TestGetRoadmapSyncIdentityByWorkItem_NotFoundSentinel(t *testing.T) {
	m := &identityMockDBTX{byWork: identityRouteResult{err: pgx.ErrNoRows}}
	store := newIdentityStore(m)

	_, err := store.GetRoadmapSyncIdentityByWorkItem(context.Background(), db.GetRoadmapSyncIdentityByWorkItemParams{
		Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
	})
	if !errors.Is(err, ErrRoadmapSyncIdentityNotFound) {
		t.Fatalf("expected not-found sentinel, got %v", err)
	}
}

func TestGetRoadmapSyncIdentityByMilestonePath_ReturnsRow(t *testing.T) {
	m := &identityMockDBTX{byPath: identityRouteResult{row: db.RoadmapSyncIdentity{
		ID: 4, RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", WorkItemID: "NOMAD-13",
	}}}
	store := newIdentityStore(m)

	row, err := store.GetRoadmapSyncIdentityByMilestonePath(context.Background(), "agent-roadmap/phase/p/milestones/m.md")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if row.ID != 4 || row.WorkItemID != "NOMAD-13" {
		t.Errorf("row not preserved: %+v", row)
	}
}

func sqlLogContains(logs []string, sub string) bool {
	for _, l := range logs {
		if strings.Contains(l, sub) {
			return true
		}
	}
	return false
}
