package scheduler

import (
	"bytes"
	"context"
	"fmt"
	"strings"
	"sync"
	"testing"
	"time"

	"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
	"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
)

type fakeExecutor struct {
	mu           sync.Mutex
	activeCount  int
	maxActive    int
	executedJobs []jobs.Job
	executeFn    func(ctx context.Context, job jobs.Job) error
	// resultFn controls ExecuteWithResult returns.
	// nil = return (Result{}, false, nil) — legacy no-result path.
	resultFn func(ctx context.Context, job jobs.Job) (jobs.Result, bool, error)
}

func (f *fakeExecutor) Execute(ctx context.Context, job jobs.Job) error {
	f.mu.Lock()
	f.activeCount++
	if f.activeCount > f.maxActive {
		f.maxActive = f.activeCount
	}
	f.executedJobs = append(f.executedJobs, job)
	f.mu.Unlock()

	defer func() {
		f.mu.Lock()
		f.activeCount--
		f.mu.Unlock()
	}()

	if f.executeFn != nil {
		return f.executeFn(ctx, job)
	}
	return nil
}

func (f *fakeExecutor) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.resultFn != nil {
		return f.resultFn(ctx, job)
	}
	// Default: legacy no-result path.
	return jobs.Result{}, false, nil
}

func TestRunTickDispatchesItemsWithBoundedParallelism(t *testing.T) {
	resetActiveJobs()
	cfg := Config{
		Schedules: []ScheduleConfig{
			{
				Name:     "kr-daily-parallel",
				Provider: "kis",
				Selector: SelectorConfig{
					Kind:    "watchlist",
					Market:  "KR",
					Venue:   "KRX",
					Name:    "watchlist1",
					Symbols: []string{"A", "B", "C", "D"},
				},
				Timeframe:        "daily",
				Cadence:          "daily",
				Timezone:         "Asia/Seoul",
				BackfillWindow:   "3d",
				ParallelismLimit: 2,
			},
		},
	}

	exec := &fakeExecutor{
		executeFn: func(ctx context.Context, job jobs.Job) error {
			time.Sleep(10 * time.Millisecond)
			return nil
		},
	}

	opts := TickOptions{
		Now:          time.Date(2026, 6, 21, 10, 30, 0, 0, time.UTC),
		Capabilities: CapabilityMap(testCapability()),
		Executor:     exec,
	}

	res, err := RunTick(context.Background(), cfg, opts)
	if err != nil {
		t.Fatalf("RunTick: %v", err)
	}

	if len(res.Items) != 4 {
		t.Errorf("expected 4 item results, got %d", len(res.Items))
	}

	if exec.maxActive > 2 {
		t.Errorf("max concurrent jobs %d exceeded parallelism limit 2", exec.maxActive)
	}

	for _, item := range res.Items {
		if item.Status != "succeeded" {
			t.Errorf("expected succeeded status, got %s", item.Status)
		}
	}
}

func TestRunTickSkipsDuplicateRunningWindow(t *testing.T) {
	resetActiveJobs()
	cfg := Config{
		Schedules: []ScheduleConfig{
			{
				Name:     "kr-daily-dup",
				Provider: "kis",
				Selector: SelectorConfig{
					Kind:    "watchlist",
					Market:  "KR",
					Venue:   "KRX",
					Name:    "watchlist1",
					Symbols: []string{"A"},
				},
				Timeframe:        "daily",
				Cadence:          "daily",
				Timezone:         "Asia/Seoul",
				BackfillWindow:   "3d",
				ParallelismLimit: 1,
			},
		},
	}

	startChan := make(chan struct{})
	doneChan := make(chan struct{})

	exec := &fakeExecutor{
		executeFn: func(ctx context.Context, job jobs.Job) error {
			close(startChan)
			<-doneChan
			return nil
		},
	}

	opts := TickOptions{
		Now:          time.Date(2026, 6, 21, 10, 30, 0, 0, time.UTC),
		Capabilities: CapabilityMap(testCapability()),
		Executor:     exec,
	}

	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		_, _ = RunTick(context.Background(), cfg, opts)
	}()

	<-startChan // Wait until first tick starts executing

	res2, err := RunTick(context.Background(), cfg, opts)
	if err != nil {
		t.Fatalf("second RunTick: %v", err)
	}

	if len(res2.Items) != 1 {
		t.Fatalf("expected 1 result in duplicate run, got %d", len(res2.Items))
	}
	if res2.Items[0].Status != "skipped" {
		t.Errorf("expected duplicate run to be skipped, got %s", res2.Items[0].Status)
	}
	if res2.Items[0].Error != "duplicate window running" {
		t.Errorf("expected error string duplicate window running, got %q", res2.Items[0].Error)
	}

	close(doneChan)
	wg.Wait()

	// Sequential 2nd run
	exec3 := &fakeExecutor{}
	opts3 := TickOptions{
		Now:          time.Date(2026, 6, 21, 10, 30, 0, 0, time.UTC),
		Capabilities: CapabilityMap(testCapability()),
		Executor:     exec3,
	}
	res3, err := RunTick(context.Background(), cfg, opts3)
	if err != nil {
		t.Fatalf("third sequential RunTick: %v", err)
	}
	if len(res3.Items) != 1 {
		t.Fatalf("expected 1 result, got %d", len(res3.Items))
	}
	if res3.Items[0].Status != "succeeded" {
		t.Errorf("expected succeeded status, got %s", res3.Items[0].Status)
	}
}

func TestWriteTickJSONLIncludesReadinessFields(t *testing.T) {
	res := TickResult{
		Items: []ItemResult{
			{
				Schedule:  "test-sched",
				Symbol:    "AAPL",
				Status:    "succeeded",
				Provider:  "kis",
				Timeframe: "daily",
				Readiness: "ready",
				Freshness: "fresh",
			},
			{
				Schedule:  "test-sched",
				Symbol:    "MSFT",
				Status:    "failed",
				Provider:  "kis",
				Timeframe: "daily",
				Readiness: "error",
				Freshness: "stale",
				Error:     "network error",
			},
		},
	}

	var jsonl bytes.Buffer
	if err := WriteTickJSONL(&jsonl, res); err != nil {
		t.Fatalf("WriteTickJSONL: %v", err)
	}

	lines := strings.Split(strings.TrimSpace(jsonl.String()), "\n")
	if len(lines) != 2 {
		t.Fatalf("expected 2 JSONL lines, got %d", len(lines))
	}

	if !strings.Contains(lines[0], `"readiness":"ready"`) || !strings.Contains(lines[0], `"freshness":"fresh"`) {
		t.Errorf("line 0 missing readiness/freshness fields: %s", lines[0])
	}
	if !strings.Contains(lines[1], `"readiness":"error"`) || !strings.Contains(lines[1], `"freshness":"stale"`) {
		t.Errorf("line 1 missing readiness/freshness fields: %s", lines[1])
	}

	var text bytes.Buffer
	if err := WriteTickText(&text, res); err != nil {
		t.Fatalf("WriteTickText: %v", err)
	}
	textLines := strings.Split(strings.TrimSpace(text.String()), "\n")
	if len(textLines) != 2 {
		t.Fatalf("expected 2 text lines, got %d", len(textLines))
	}
	if !strings.Contains(textLines[0], "readiness=ready freshness=fresh") {
		t.Errorf("text line 0 missing readiness/freshness: %s", textLines[0])
	}
	if !strings.Contains(textLines[1], "readiness=error freshness=stale") {
		t.Errorf("text line 1 missing readiness/freshness: %s", textLines[1])
	}
}

type fakeDailyBarImporter struct {
	mu           sync.Mutex
	upsertedBars map[string]int
}

func (f *fakeDailyBarImporter) ImportDailyBars(ctx context.Context, request importer.DailyBarRequest) (importer.Result, error) {
	f.mu.Lock()
	defer f.mu.Unlock()

	timeframe := "daily"
	days := int(request.To.Sub(request.From).Hours()/24) + 1
	for _, sym := range request.Selector.Symbols {
		for i := 0; i < days; i++ {
			t := request.From.Add(time.Duration(i) * 24 * time.Hour)
			dateStr := t.Format("2006-01-02")
			key := fmt.Sprintf("%s:%s:%s", sym, timeframe, dateStr)
			f.upsertedBars[key]++
		}
	}
	return importer.Result{
		Instruments: len(request.Selector.Symbols),
		Bars:        len(request.Selector.Symbols) * days,
	}, nil
}

func TestRunTickSequentialTicksDoNotDuplicateBarKeys(t *testing.T) {
	resetActiveJobs()

	fakeImp := &fakeDailyBarImporter{
		upsertedBars: make(map[string]int),
	}

	runner := jobs.NewRunner()
	jobs.RegisterBuiltins(runner)
	cap := testCapability()
	jobs.RegisterDailyBarImportHandler(runner, cap, fakeImp)

	cfg := Config{
		Schedules: []ScheduleConfig{
			{
				Name:     "kr-daily-dup-assert",
				Provider: "kis",
				Selector: SelectorConfig{
					Kind:    "watchlist",
					Market:  "KR",
					Venue:   "KRX",
					Name:    "watchlist1",
					Symbols: []string{"005930"},
				},
				Timeframe:        "daily",
				Cadence:          "daily",
				Timezone:         "Asia/Seoul",
				BackfillWindow:   "3d",
				ParallelismLimit: 1,
			},
		},
	}

	opts := TickOptions{
		Now:          time.Date(2026, 6, 21, 10, 30, 0, 0, time.UTC),
		Capabilities: CapabilityMap(cap),
		Executor:     runner,
	}

	// 1st tick
	res1, err := RunTick(context.Background(), cfg, opts)
	if err != nil {
		t.Fatalf("first tick: %v", err)
	}
	if len(res1.Items) != 1 || res1.Items[0].Status != "succeeded" {
		t.Fatalf("expected succeeded in 1st tick, got: %+v", res1.Items)
	}

	fakeImp.mu.Lock()
	keysCount1 := len(fakeImp.upsertedBars)
	if keysCount1 == 0 {
		fakeImp.mu.Unlock()
		t.Fatal("expected upserted bars count > 0")
	}
	for k, count := range fakeImp.upsertedBars {
		if count != 1 {
			t.Errorf("expected key %s count to be 1, got %d", k, count)
		}
	}
	fakeImp.mu.Unlock()

	// 2nd tick
	res2, err := RunTick(context.Background(), cfg, opts)
	if err != nil {
		t.Fatalf("second tick: %v", err)
	}
	if len(res2.Items) != 1 || res2.Items[0].Status != "succeeded" {
		t.Fatalf("expected succeeded in 2nd tick, got: %+v", res2.Items)
	}

	fakeImp.mu.Lock()
	keysCount2 := len(fakeImp.upsertedBars)
	if keysCount2 != keysCount1 {
		t.Errorf("expected unique key count to be %d, got %d (duplicated keys detected)", keysCount1, keysCount2)
	}
	for k, count := range fakeImp.upsertedBars {
		if count != 2 {
			t.Errorf("expected key %s count to be 2 after second tick, got %d", k, count)
		}
	}
	fakeImp.mu.Unlock()
}

// --- ExecuteWithResult error propagation tests ---

func TestRunTickResultHandlerErrorDoesNotReexecute(t *testing.T) {
	// When ExecuteWithResult returns (Result{}, false, err), the scheduler
	// must record "failed" WITHOUT calling Execute() again.  This verifies
	// the fix for the original bug where the first error was lost.
	resetActiveJobs()

	wantErrMsg := "import failed: connection reset"
	exec := &fakeExecutor{
		resultFn: func(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
			return jobs.Result{}, false, fmt.Errorf(wantErrMsg)
		},
	}

	cfg := Config{
		Schedules: []ScheduleConfig{
			{
				Name:     "error-test",
				Provider: "kis",
				Selector: SelectorConfig{
					Kind:    "watchlist",
					Market:  "KR",
					Venue:   "KRX",
					Name:    "watchlist1",
					Symbols: []string{"A"},
				},
				Timeframe:        "daily",
				Cadence:          "daily",
				Timezone:         "Asia/Seoul",
				BackfillWindow:   "3d",
				ParallelismLimit: 1,
			},
		},
	}

	opts := TickOptions{
		Now:          time.Date(2026, 6, 21, 10, 30, 0, 0, time.UTC),
		Capabilities: CapabilityMap(testCapability()),
		Executor:     exec,
	}

	res, err := RunTick(context.Background(), cfg, opts)
	if err != nil {
		t.Fatalf("RunTick: %v", err)
	}

	if len(res.Items) != 1 {
		t.Fatalf("expected 1 item, got %d", len(res.Items))
	}

	item := res.Items[0]
	if item.Status != "failed" {
		t.Errorf("expected status=failed, got %s", item.Status)
	}
	if item.Readiness != "error" {
		t.Errorf("expected readiness=error, got %s", item.Readiness)
	}
	if item.Freshness != "stale" {
		t.Errorf("expected freshness=stale, got %s", item.Freshness)
	}
	if !strings.Contains(item.Error, wantErrMsg) {
		t.Errorf("expected error to contain %q, got %q", wantErrMsg, item.Error)
	}
	if item.ImportSummary != nil {
		t.Errorf("expected nil ImportSummary on failure, got %+v", item.ImportSummary)
	}
}

func TestRunTickResultHandlerSuccessWithBars(t *testing.T) {
	// Verify that a successful ExecuteWithResult returns bars into ImportSummary.
	resetActiveJobs()

	exec := &fakeExecutor{
		resultFn: func(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
			return jobs.Result{Bars: 42, GapBars: 2}, true, nil
		},
	}

	cfg := Config{
		Schedules: []ScheduleConfig{
			{
				Name:     "success-test",
				Provider: "kis",
				Selector: SelectorConfig{
					Kind:    "watchlist",
					Market:  "KR",
					Venue:   "KRX",
					Name:    "watchlist1",
					Symbols: []string{"A"},
				},
				Timeframe:        "daily",
				Cadence:          "daily",
				Timezone:         "Asia/Seoul",
				BackfillWindow:   "3d",
				ParallelismLimit: 1,
			},
		},
	}

	opts := TickOptions{
		Now:          time.Date(2026, 6, 21, 10, 30, 0, 0, time.UTC),
		Capabilities: CapabilityMap(testCapability()),
		Executor:     exec,
	}

	res, err := RunTick(context.Background(), cfg, opts)
	if err != nil {
		t.Fatalf("RunTick: %v", err)
	}

	if len(res.Items) != 1 {
		t.Fatalf("expected 1 item, got %d", len(res.Items))
	}

	item := res.Items[0]
	if item.Status != "succeeded" {
		t.Errorf("expected status=succeeded, got %s", item.Status)
	}
	if item.ImportSummary == nil {
		t.Fatal("expected ImportSummary, got nil")
	}
	if item.ImportSummary.ImportedBars != 42 {
		t.Errorf("expected ImportedBars=42, got %d", item.ImportSummary.ImportedBars)
	}
	if item.ImportSummary.GapBars != 2 {
		t.Errorf("expected GapBars=2, got %d", item.ImportSummary.GapBars)
	}
}

func TestRunTickNoResultHandlerFallsBackToExecute(t *testing.T) {
	// When resultFn is nil, ExecuteWithResult returns (Result{}, false, nil).
	// The scheduler must fall back to Execute() for success/error.
	fallbackExecuted := false
	exec := &fakeExecutor{
		// resultFn is nil — default legacy path.
		executeFn: func(ctx context.Context, job jobs.Job) error {
			fallbackExecuted = true
			return nil
		},
	}

	cfg := Config{
		Schedules: []ScheduleConfig{
			{
				Name:     "fallback-test",
				Provider: "kis",
				Selector: SelectorConfig{
					Kind:    "watchlist",
					Market:  "KR",
					Venue:   "KRX",
					Name:    "watchlist1",
					Symbols: []string{"A"},
				},
				Timeframe:        "daily",
				Cadence:          "daily",
				Timezone:         "Asia/Seoul",
				BackfillWindow:   "3d",
				ParallelismLimit: 1,
			},
		},
	}

	opts := TickOptions{
		Now:          time.Date(2026, 6, 21, 10, 30, 0, 0, time.UTC),
		Capabilities: CapabilityMap(testCapability()),
		Executor:     exec,
	}

	res, err := RunTick(context.Background(), cfg, opts)
	if err != nil {
		t.Fatalf("RunTick: %v", err)
	}

	if len(res.Items) != 1 {
		t.Fatalf("expected 1 item, got %d", len(res.Items))
	}

	item := res.Items[0]
	if item.Status != "succeeded" {
		t.Errorf("expected status=succeeded, got %s", item.Status)
	}
	if !fallbackExecuted {
		t.Error("expected Execute() to be called as fallback, but it was not")
	}
	if item.ImportSummary != nil {
		t.Errorf("expected nil ImportSummary for no-evidence success, got %+v", item.ImportSummary)
	}
}

func TestRunTickOutputCarriesS02Evidence(t *testing.T) {
	res := TickResult{
		Items: []ItemResult{
			{
				Schedule:  "test-sched",
				Symbol:    "AAPL",
				Status:    "succeeded",
				Provider:  "kis",
				Timeframe: "daily",
				Readiness: "ready",
				Freshness: "fresh",
			},
		},
	}

	var jsonl bytes.Buffer
	if err := WriteTickJSONL(&jsonl, res); err != nil {
		t.Fatalf("WriteTickJSONL: %v", err)
	}

	outStr := jsonl.String()
	for _, expected := range []string{`"status":"succeeded"`, `"readiness":"ready"`, `"freshness":"fresh"`, `"schedule":"test-sched"`, `"symbol":"AAPL"`} {
		if !strings.Contains(outStr, expected) {
			t.Errorf("JSONL output missing expected pattern %q: %s", expected, outStr)
		}
	}

	var text bytes.Buffer
	if err := WriteTickText(&text, res); err != nil {
		t.Fatalf("WriteTickText: %v", err)
	}

	textStr := text.String()
	for _, expected := range []string{"status=succeeded", "readiness=ready", "freshness=fresh", "schedule=test-sched", "symbol=AAPL"} {
		if !strings.Contains(textStr, expected) {
			t.Errorf("Text output missing expected pattern %q: %s", expected, textStr)
		}
	}
}
