package operator

import (
	"bytes"
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
)

func runScenario(t *testing.T, sc *Scenario, url string) (string, int) {
	t.Helper()
	var stdout bytes.Buffer
	code := RunScenario(context.Background(), sc, RunOptions{
		APIURL:  url,
		Timeout: 2 * time.Second,
		Format:  OutputText,
	}, &stdout)
	return stdout.String(), code
}

func TestRunScenarioAPISmoke(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{helloCaps: []string{"hello", "request-response"}})
	sc := &Scenario{
		Name:  "api_connection_smoke",
		Steps: []Step{{ID: "hello", Action: ActionHello, Expect: Expect{Status: "ok", Capabilities: []string{"hello"}}}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	if !strings.Contains(out, "status=ok") {
		t.Errorf("output %q missing status=ok", out)
	}
	if strings.Contains(out, "instrument_count") {
		t.Errorf("output %q should not contain instrument_count for hello action", out)
	}
	if strings.Contains(out, "bar_count") {
		t.Errorf("output %q should not contain bar_count for hello action", out)
	}
}

func TestRunScenarioMarketDataStatus(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{
		helloCaps:           []string{"market-read"},
		importDailyBarsResp: &altv1.ImportDailyBarsResponse{Provider: "kis", InstrumentCount: 1, BarCount: 42},
		instResp:            &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "KRX:005930", Symbol: "005930"}}},
		barsResp:            &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "KRX:005930", TimestampUnixMs: 1746057600000}}}, // 20250501
	})
	sc, err := LoadScenario(fixture(t, "market_data_status_query.yaml"))
	if err != nil {
		t.Fatalf("load scenario: %v", err)
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	for _, want := range []string{
		"scenario=market_data_status_query",
		// import-before-read evidence: import counts are in the status output.
		"step=import_kr_daily_bars",
		"action=import_daily_bars",
		"provider=kis",
		"instrument_count=1",
		"bar_count=42",
		// list/read status counts still report.
		"step=list_kr_instruments",
		"action=list_instruments",
		"step=list_daily_bars",
		"action=list_bars",
		"status=ok",
		"count=1",
		"step=freshness_status",
		"action=collection_freshness",
		"universe=kr-core",
		"freshness_status=fresh",
		"missing_count=0",
		"latest_yyyymmdd=20250501",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}
}

func TestRunScenarioMarketDataStatusUS(t *testing.T) {
	api := &fakeAPI{
		helloCaps:           []string{"market-read"},
		importDailyBarsResp: &altv1.ImportDailyBarsResponse{Provider: "kis", InstrumentCount: 1, BarCount: 42},
		instResp:            &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "NASDAQ:AAPL", Symbol: "AAPL"}}},
		barsResp:            &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "NASDAQ:AAPL", TimestampUnixMs: 1716940800000}}}, // 20240529
	}
	url := startFakeAPIServer(t, api)
	sc, err := LoadScenario(fixture(t, "us_market_data_status_query.yaml"))
	if err != nil {
		t.Fatalf("load scenario: %v", err)
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	for _, want := range []string{
		"scenario=us_market_data_status_query",
		// import-before-read evidence: import counts are in the status output.
		"step=import_us_daily_bars",
		"action=import_daily_bars",
		"provider=kis",
		"instrument_count=1",
		"bar_count=42",
		// list/read status counts still report.
		"step=list_us_instruments",
		"action=list_instruments",
		"step=list_us_daily_bars",
		"action=list_bars",
		"status=ok",
		"count=1",
		"step=freshness_status",
		"action=collection_freshness",
		"universe=us-core",
		"freshness_status=fresh",
		"missing_count=0",
		"latest_yyyymmdd=20240529",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}

	// Verify imported US request fields
	imp := api.lastImportDailyBarsReq()
	if imp == nil {
		t.Fatal("server did not receive an import_daily_bars request")
	}
	if imp.GetProvider() != "kis" || imp.GetFromYyyymmdd() != "20240528" || imp.GetToYyyymmdd() != "20240529" {
		t.Errorf("import request = %+v, want kis 20240528..20240529", imp)
	}
	if imp.GetSelectorKind() != "watchlist" {
		t.Errorf("selector_kind = %q, want watchlist", imp.GetSelectorKind())
	}
	if imp.GetMarket() != altv1.Market_MARKET_US {
		t.Errorf("market = %v, want MARKET_US", imp.GetMarket())
	}
	if imp.GetVenue() != altv1.Venue_VENUE_NASDAQ {
		t.Errorf("venue = %v, want VENUE_NASDAQ", imp.GetVenue())
	}
	if imp.GetName() != "us-core" {
		t.Errorf("name = %q, want us-core", imp.GetName())
	}
	if !equalStrings(imp.GetSymbols(), []string{"AAPL"}) {
		t.Errorf("symbols = %v, want [AAPL]", imp.GetSymbols())
	}
}

// TestRunScenarioMarketDataStatusImportsBeforeReads proves the command-first
// status scenario issues its import_daily_bars call before the instrument and
// bar status reads, so the reported counts describe freshly imported data.
func TestRunScenarioMarketDataStatusImportsBeforeReads(t *testing.T) {
	api := &fakeAPI{
		helloCaps:           []string{"market-read"},
		importDailyBarsResp: &altv1.ImportDailyBarsResponse{Provider: "kis", InstrumentCount: 1, BarCount: 42},
		instResp:            &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "KRX:005930", Symbol: "005930"}}},
		barsResp:            &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "KRX:005930", TimestampUnixMs: 1746057600000}}}, // 20250501
	}
	url := startFakeAPIServer(t, api)
	sc, err := LoadScenario(fixture(t, "market_data_status_query.yaml"))
	if err != nil {
		t.Fatalf("load scenario: %v", err)
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}

	// The import actually carried the fixture's KIS window, not a default read.
	imp := api.lastImportDailyBarsReq()
	if imp == nil {
		t.Fatal("server did not receive an import_daily_bars request")
	}
	if imp.GetProvider() != "kis" || imp.GetFromYyyymmdd() != "20240527" || imp.GetToYyyymmdd() != "20240528" {
		t.Errorf("import request = %+v, want kis 20240527..20240528", imp)
	}
	if imp.GetSelectorKind() != "watchlist" {
		t.Errorf("selector_kind = %q, want watchlist", imp.GetSelectorKind())
	}
	if imp.GetMarket() != altv1.Market_MARKET_KR {
		t.Errorf("market = %v, want MARKET_KR", imp.GetMarket())
	}
	if imp.GetVenue() != altv1.Venue_VENUE_KRX {
		t.Errorf("venue = %v, want VENUE_KRX", imp.GetVenue())
	}
	if imp.GetName() != "kr-core" {
		t.Errorf("name = %q, want kr-core", imp.GetName())
	}
	if !equalStrings(imp.GetSymbols(), []string{"005930"}) {
		t.Errorf("symbols = %v, want [005930]", imp.GetSymbols())
	}

	// Call order: import precedes both status reads.
	wantOrder := []string{"import_daily_bars", "list_instruments", "list_bars", "list_instruments", "list_bars"}
	if got := api.callOrder(); !equalStrings(got, wantOrder) {
		t.Errorf("call order = %v, want %v (out=%q)", got, wantOrder, out)
	}
}

// TestRunScenarioMarketStatusTypedUnavailable proves the status scenario surfaces
// a typed unavailable ErrorInfo in its machine-readable output when the import
// step fails, instead of degrading into a transport error.
func TestRunScenarioMarketStatusTypedUnavailable(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{
		importDailyBarsResp: &altv1.ImportDailyBarsResponse{
			Error: &altv1.ErrorInfo{Code: "unavailable", Message: "market worker offline"},
		},
	})
	sc := &Scenario{
		Name: "market_status_typed_unavailable",
		Steps: []Step{{
			ID:     "import_kr_daily_bars",
			Action: ActionImportDailyBars,
			Request: Request{
				Provider:     "kis",
				SelectorKind: "watchlist",
				Symbols:      []string{"005930"},
			},
			Expect: Expect{Status: "error", ErrorCode: "unavailable"},
		}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	if !strings.Contains(out, "status=ok") || !strings.Contains(out, "error.code=unavailable") {
		t.Errorf("output %q missing expected typed unavailable markers", out)
	}
}

// equalStrings reports whether two string slices have the same elements in order.
func equalStrings(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

func TestRunScenarioMarketEmptyResult(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{instResp: &altv1.ListInstrumentsResponse{}})
	sc := &Scenario{
		Name:  "empty_market",
		Steps: []Step{{ID: "insts", Action: ActionListInstruments, Request: Request{Market: "kr"}, Expect: Expect{Status: "ok"}}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	if !strings.Contains(out, "count=0") {
		t.Errorf("output %q missing count=0", out)
	}
}

func TestRunScenarioUnexpectedTypedError(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{instResp: &altv1.ListInstrumentsResponse{
		Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "bad market"},
	}})
	sc := &Scenario{
		Name:  "unexpected_error",
		Steps: []Step{{ID: "insts", Action: ActionListInstruments, Request: Request{Market: "kr"}, Expect: Expect{Status: "ok"}}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeAppError {
		t.Fatalf("exit code = %d, want 1 (out=%q)", code, out)
	}
	if !strings.Contains(out, "status=error") || !strings.Contains(out, "error.code=invalid_request") {
		t.Errorf("output %q missing typed error markers", out)
	}
}

func TestRunScenarioExpectedTypedError(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{instResp: &altv1.ListInstrumentsResponse{
		Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "bad market"},
	}})
	sc := &Scenario{
		Name:  "expected_error",
		Steps: []Step{{ID: "insts", Action: ActionListInstruments, Request: Request{Market: "kr"}, Expect: Expect{Status: "error", ErrorCode: "invalid_request"}}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	if !strings.Contains(out, "error.code=invalid_request") {
		t.Errorf("output %q missing error.code=invalid_request", out)
	}
}

func TestRunScenarioMinCountMismatch(t *testing.T) {
	// Server returns zero instruments but the step requires at least one, so the
	// expectation fails (not a typed error, not a transport error).
	url := startFakeAPIServer(t, &fakeAPI{instResp: &altv1.ListInstrumentsResponse{}})
	sc := &Scenario{
		Name:  "min_count_mismatch",
		Steps: []Step{{ID: "insts", Action: ActionListInstruments, Request: Request{Market: "kr"}, Expect: Expect{Status: "ok", MinCount: intPtr(1)}}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeAppError {
		t.Fatalf("exit code = %d, want 1 (out=%q)", code, out)
	}
	if !strings.Contains(out, "status=mismatch") {
		t.Errorf("output %q missing status=mismatch", out)
	}
}

func TestRunScenarioWrongExpectedErrorCode(t *testing.T) {
	// Server returns a typed error, but its code differs from the expected code:
	// an expectation mismatch distinct from an unexpected error.
	url := startFakeAPIServer(t, &fakeAPI{instResp: &altv1.ListInstrumentsResponse{
		Error: &altv1.ErrorInfo{Code: "unavailable", Message: "market worker unavailable"},
	}})
	sc := &Scenario{
		Name:  "wrong_error_code",
		Steps: []Step{{ID: "insts", Action: ActionListInstruments, Request: Request{Market: "kr"}, Expect: Expect{Status: "error", ErrorCode: "invalid_request"}}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeAppError {
		t.Fatalf("exit code = %d, want 1 (out=%q)", code, out)
	}
	if !strings.Contains(out, "status=mismatch") {
		t.Errorf("output %q missing status=mismatch", out)
	}
	if !strings.Contains(out, "error.code=unavailable") {
		t.Errorf("output %q missing actual error.code=unavailable", out)
	}
}

func TestRunScenarioTransportFailure(t *testing.T) {
	// A reserved-then-released port is almost certainly closed, so the dial fails
	// before any typed response.
	port := freePort(t)
	url := "ws://127.0.0.1:" + itoa(port) + "/socket"
	sc := &Scenario{
		Name:  "transport_failure",
		Steps: []Step{{ID: "hello", Action: ActionHello, Expect: Expect{Status: "ok"}}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeTransport {
		t.Fatalf("exit code = %d, want 3 (out=%q)", code, out)
	}
	if !strings.Contains(out, "status=transport_error") {
		t.Errorf("output %q missing status=transport_error", out)
	}
}

func TestRunScenarioImportDailyBarsSuccess(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{
		importDailyBarsResp: &altv1.ImportDailyBarsResponse{
			Provider:        "kis",
			InstrumentCount: 2,
			BarCount:        100,
		},
	})
	sc := &Scenario{
		Name: "import_daily_bars_success",
		Steps: []Step{{
			ID:     "import1",
			Action: ActionImportDailyBars,
			Request: Request{
				Provider:     "kis",
				SelectorKind: "watchlist",
				Market:       "kr",
				Venue:        "krx",
				Symbols:      []string{"005930", "000660"},
			},
			Expect: Expect{
				Status: "ok",
			},
		}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	for _, want := range []string{
		"scenario=import_daily_bars_success",
		"step=import1",
		"action=import_daily_bars",
		"status=ok",
		"provider=kis",
		"instrument_count=2",
		"bar_count=100",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}
}

func TestRunScenarioImportDailyBarsMapsUSVenues(t *testing.T) {
	tests := []struct {
		name  string
		venue string
		want  altv1.Venue
	}{
		{name: "nasdaq", venue: "nasdaq", want: altv1.Venue_VENUE_NASDAQ},
		{name: "nyse", venue: "nyse", want: altv1.Venue_VENUE_NYSE},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			api := &fakeAPI{
				importDailyBarsResp: &altv1.ImportDailyBarsResponse{
					Provider:        "kis",
					InstrumentCount: 1,
					BarCount:        1,
				},
			}
			url := startFakeAPIServer(t, api)
			sc := &Scenario{
				Name: "import_us_daily_bars",
				Steps: []Step{{
					ID:     "import1",
					Action: ActionImportDailyBars,
					Request: Request{
						Provider:     "kis",
						SelectorKind: "watchlist",
						Market:       "us",
						Venue:        tt.venue,
						Symbols:      []string{"AAPL"},
					},
					Expect: Expect{Status: "ok"},
				}},
			}

			out, code := runScenario(t, sc, url)
			if code != codeOK {
				t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
			}
			req := api.lastImportDailyBarsReq()
			if req == nil {
				t.Fatal("server did not receive an import_daily_bars request")
			}
			if req.GetMarket() != altv1.Market_MARKET_US {
				t.Errorf("market = %v, want MARKET_US", req.GetMarket())
			}
			if req.GetVenue() != tt.want {
				t.Errorf("venue = %v, want %v", req.GetVenue(), tt.want)
			}
		})
	}
}

func TestRunScenarioImportDailyBarsExpectedTypedError(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{
		importDailyBarsResp: &altv1.ImportDailyBarsResponse{
			Error: &altv1.ErrorInfo{Code: "unavailable", Message: "worker offline"},
		},
	})
	sc := &Scenario{
		Name: "import_expected_error",
		Steps: []Step{{
			ID:     "import1",
			Action: ActionImportDailyBars,
			Request: Request{
				Provider:     "kis",
				SelectorKind: "watchlist",
				Symbols:      []string{"005930"},
			},
			Expect: Expect{
				Status:    "error",
				ErrorCode: "unavailable",
			},
		}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	if !strings.Contains(out, "error.code=unavailable") {
		t.Errorf("output %q missing error.code=unavailable", out)
	}
}

func TestRunScenarioImportDailyBarsUnexpectedTypedError(t *testing.T) {
	url := startFakeAPIServer(t, &fakeAPI{
		importDailyBarsResp: &altv1.ImportDailyBarsResponse{
			Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "bad date format"},
		},
	})
	sc := &Scenario{
		Name: "import_unexpected_error",
		Steps: []Step{{
			ID:     "import1",
			Action: ActionImportDailyBars,
			Request: Request{
				Provider:     "kis",
				SelectorKind: "watchlist",
				Symbols:      []string{"005930"},
			},
			Expect: Expect{
				Status: "ok",
			},
		}},
	}

	out, code := runScenario(t, sc, url)
	if code != codeAppError {
		t.Fatalf("exit code = %d, want 1 (out=%q)", code, out)
	}
	if !strings.Contains(out, "status=error") || !strings.Contains(out, "error.code=invalid_request") {
		t.Errorf("output %q missing error status or invalid_request code", out)
	}
}

func intPtr(n int) *int { return &n }

// itoa avoids importing strconv just for one conversion in tests.
func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var digits []byte
	for n > 0 {
		digits = append([]byte{byte('0' + n%10)}, digits...)
		n /= 10
	}
	return string(digits)
}

func TestRunScenarioCollectionFreshnessFresh(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"market-read"},
		instResp: &altv1.ListInstrumentsResponse{
			Instruments: []*altv1.Instrument{
				{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
				{Id: "KRX:000660", Symbol: "000660", Market: altv1.Market_MARKET_KR},
			},
		},
		barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
			if req.GetInstrumentId() == "KRX:005930" {
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{{InstrumentId: "KRX:005930", TimestampUnixMs: 1716768000000}}, // 20240527
				}, nil
			}
			if req.GetInstrumentId() == "KRX:000660" {
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{{InstrumentId: "KRX:000660", TimestampUnixMs: 1716854400000}}, // 20240528
				}, nil
			}
			return &altv1.ListBarsResponse{}, nil
		},
	}
	url := startFakeAPIServer(t, api)
	sc := &Scenario{
		Name: "collection_freshness_fresh",
		Universes: []UniverseConfig{{
			Name:         "kr-core",
			Provider:     "kis",
			SelectorKind: "watchlist",
			Market:       "kr",
			Venue:        "krx",
			Symbols:      []string{"005930", "000660"},
		}},
		Steps: []Step{{
			ID:     "freshness1",
			Action: ActionCollectionFreshness,
			Request: Request{
				Universe:   "kr-core",
				Timeframe:  "daily",
				FromUnixMs: 1716768000000,
				ToUnixMs:   1716854400000,
			},
			Expect: Expect{
				Status:          "ok",
				FreshnessStatus: "fresh",
				MissingCount:    intPtr(0),
				LatestYYYYMMDD:  "20240528",
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}

	for _, want := range []string{
		"scenario=collection_freshness_fresh",
		"step=freshness1",
		"action=collection_freshness",
		"status=ok",
		"universe=kr-core",
		"freshness_status=fresh",
		"missing_count=0",
		"latest_yyyymmdd=20240528",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}
}

func TestRunScenarioCollectionFreshnessMissingSymbol(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"market-read"},
		instResp: &altv1.ListInstrumentsResponse{
			Instruments: []*altv1.Instrument{
				{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
				{Id: "KRX:000660", Symbol: "000660", Market: altv1.Market_MARKET_KR},
			},
		},
		barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
			if req.GetInstrumentId() == "KRX:005930" {
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{{InstrumentId: "KRX:005930", TimestampUnixMs: 1716768000000}}, // 20240527
				}, nil
			}
			return &altv1.ListBarsResponse{}, nil
		},
	}
	url := startFakeAPIServer(t, api)
	sc := &Scenario{
		Name: "collection_freshness_missing",
		Universes: []UniverseConfig{{
			Name:         "kr-core",
			Provider:     "kis",
			SelectorKind: "watchlist",
			Market:       "kr",
			Venue:        "krx",
			Symbols:      []string{"005930", "000660", "000020"},
		}},
		Steps: []Step{{
			ID:     "freshness1",
			Action: ActionCollectionFreshness,
			Request: Request{
				Universe:   "kr-core",
				Timeframe:  "daily",
				FromUnixMs: 1716768000000,
				ToUnixMs:   1716854400000,
			},
			Expect: Expect{
				Status:          "ok",
				FreshnessStatus: "missing",
				MissingCount:    intPtr(2),
				LatestYYYYMMDD:  "20240527",
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}

	for _, want := range []string{
		"scenario=collection_freshness_missing",
		"step=freshness1",
		"action=collection_freshness",
		"status=ok",
		"universe=kr-core",
		"freshness_status=missing",
		"missing_count=2",
		"missing_symbols=000020,000660",
		"latest_yyyymmdd=20240527",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}
}

func TestRunScenarioCollectionFreshnessTypedError(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"market-read"},
		instResp: &altv1.ListInstrumentsResponse{
			Instruments: []*altv1.Instrument{
				{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
			},
		},
		barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
			return &altv1.ListBarsResponse{
				Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "bar service unavailable"},
			}, nil
		},
	}
	url := startFakeAPIServer(t, api)
	sc := &Scenario{
		Name: "collection_freshness_error",
		Universes: []UniverseConfig{{
			Name:         "kr-core",
			Provider:     "kis",
			SelectorKind: "watchlist",
			Market:       "kr",
			Venue:        "krx",
			Symbols:      []string{"005930"},
		}},
		Steps: []Step{{
			ID:     "freshness1",
			Action: ActionCollectionFreshness,
			Request: Request{
				Universe:   "kr-core",
				Timeframe:  "daily",
				FromUnixMs: 1716768000000,
				ToUnixMs:   1716854400000,
			},
			Expect: Expect{
				Status:          "error",
				FreshnessStatus: "error",
				ErrorCode:       "invalid_request",
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}

	for _, want := range []string{
		"scenario=collection_freshness_error",
		"step=freshness1",
		"action=collection_freshness",
		"status=ok",
		"freshness_status=error",
		"error.code=invalid_request",
		"error.message=\"bar service unavailable\"",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}
}

func TestRunCollectionFreshness(t *testing.T) {
	cases := []struct {
		name                 string
		barsFunc             func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error)
		expectedYYYYMMDD     []string
		expectGapStatus      string
		expectGapCount       int
		expectDuplicateCount int
		expectDelayDays      int
	}{
		{
			name: "clean",
			barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
					},
				}, nil
			},
			expectedYYYYMMDD:     []string{"20240527", "20240528"},
			expectGapStatus:      "clean",
			expectGapCount:       0,
			expectDuplicateCount: 0,
			expectDelayDays:      0,
		},
		{
			name: "gap",
			barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
				if req.GetInstrumentId() == "KRX:005930" {
					return &altv1.ListBarsResponse{
						Bars: []*altv1.Bar{
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
						},
					}, nil
				}
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
					},
				}, nil
			},
			expectedYYYYMMDD:     []string{"20240527", "20240528"},
			expectGapStatus:      "gap",
			expectGapCount:       1,
			expectDuplicateCount: 0,
			expectDelayDays:      0,
		},
		{
			name: "duplicate",
			barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
				if req.GetInstrumentId() == "KRX:000660" {
					return &altv1.ListBarsResponse{
						Bars: []*altv1.Bar{
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
						},
					}, nil
				}
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
					},
				}, nil
			},
			expectedYYYYMMDD:     []string{"20240527", "20240528"},
			expectGapStatus:      "duplicate",
			expectGapCount:       0,
			expectDuplicateCount: 1,
			expectDelayDays:      0,
		},
		{
			name: "delayed",
			barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
					},
				}, nil
			},
			expectedYYYYMMDD:     []string{"20240527", "20240528", "20240529"},
			expectGapStatus:      "delayed",
			expectGapCount:       0,
			expectDuplicateCount: 0,
			expectDelayDays:      1,
		},
		{
			name: "mixed",
			barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
				if req.GetInstrumentId() == "KRX:005930" {
					return &altv1.ListBarsResponse{
						Bars: []*altv1.Bar{
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
						},
					}, nil
				}
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716768000000}, // 20240527
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1716854400000}, // 20240528
					},
				}, nil
			},
			expectedYYYYMMDD:     []string{"20240527", "20240528", "20240529"},
			expectGapStatus:      "mixed",
			expectGapCount:       1,
			expectDuplicateCount: 0,
			expectDelayDays:      1,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			api := &fakeAPI{
				helloCaps: []string{"market-read"},
				instResp: &altv1.ListInstrumentsResponse{
					Instruments: []*altv1.Instrument{
						{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
						{Id: "KRX:000660", Symbol: "000660", Market: altv1.Market_MARKET_KR},
					},
				},
				barsRespFunc: tc.barsFunc,
			}
			url := startFakeAPIServer(t, api)
			sc := &Scenario{
				Name: "collection_freshness_gap_test",
				Universes: []UniverseConfig{{
					Name:         "kr-core",
					Provider:     "kis",
					SelectorKind: "watchlist",
					Market:       "kr",
					Venue:        "krx",
					Symbols:      []string{"005930", "000660"},
				}},
				Steps: []Step{{
					ID:     "freshness1",
					Action: ActionCollectionFreshness,
					Request: Request{
						Universe:         "kr-core",
						Timeframe:        "daily",
						FromUnixMs:       1716768000000,
						ToUnixMs:         1716940800000,
						ExpectedYYYYMMDD: tc.expectedYYYYMMDD,
					},
					Expect: Expect{
						Status:            "ok",
						GapStatus:         tc.expectGapStatus,
						GapCount:          intPtr(tc.expectGapCount),
						DuplicateCount:    intPtr(tc.expectDuplicateCount),
						ProviderDelayDays: intPtr(tc.expectDelayDays),
					},
				}},
			}
			if err := sc.Validate(); err != nil {
				t.Fatalf("scenario validate: %v", err)
			}

			out, code := runScenario(t, sc, url)
			if code != codeOK {
				t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
			}

			if !strings.Contains(out, "gap_status="+tc.expectGapStatus) {
				t.Errorf("output %q missing gap_status=%s", out, tc.expectGapStatus)
			}
			if !strings.Contains(out, "gap_count="+itoa(tc.expectGapCount)) {
				t.Errorf("output %q missing gap_count=%d", out, tc.expectGapCount)
			}
			if !strings.Contains(out, "duplicate_count="+itoa(tc.expectDuplicateCount)) {
				t.Errorf("output %q missing duplicate_count=%d", out, tc.expectDuplicateCount)
			}
			if !strings.Contains(out, "provider_delay_days="+itoa(tc.expectDelayDays)) {
				t.Errorf("output %q missing provider_delay_days=%d", out, tc.expectDelayDays)
			}
		})
	}
}

func TestRunCollectionFreshnessZeroBarRegression(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"market-read"},
		instResp: &altv1.ListInstrumentsResponse{
			Instruments: []*altv1.Instrument{
				{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
				{Id: "KRX:000660", Symbol: "000660", Market: altv1.Market_MARKET_KR},
			},
		},
		barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
			if req.GetInstrumentId() == "KRX:005930" {
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: "KRX:005930", TimestampUnixMs: 1716768000000}, // 20240527
					},
				}, nil
			}
			return &altv1.ListBarsResponse{
				Bars: []*altv1.Bar{},
			}, nil
		},
	}
	url := startFakeAPIServer(t, api)
	sc := &Scenario{
		Name: "collection_freshness_regression",
		Universes: []UniverseConfig{{
			Name:         "kr-core",
			Provider:     "kis",
			SelectorKind: "watchlist",
			Market:       "kr",
			Venue:        "krx",
			Symbols:      []string{"005930", "000660", "000020"},
		}},
		Steps: []Step{{
			ID:     "freshness1",
			Action: ActionCollectionFreshness,
			Request: Request{
				Universe:         "kr-core",
				Timeframe:        "daily",
				FromUnixMs:       1716768000000,
				ToUnixMs:         1716854400000,
				ExpectedYYYYMMDD: []string{"20240527"},
			},
			Expect: Expect{
				Status:            "ok",
				FreshnessStatus:   "missing",
				MissingCount:      intPtr(2),
				LatestYYYYMMDD:    "20240527",
				GapStatus:         "gap",
				GapCount:          intPtr(2),
				DuplicateCount:    intPtr(0),
				ProviderDelayDays: intPtr(0),
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}

	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}

	for _, want := range []string{
		"freshness_status=missing",
		"missing_count=2",
		"missing_symbols=000020,000660",
		"gap_status=gap",
		"gap_count=2",
		"latest_yyyymmdd=20240527",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}
}

func TestRunScenarioImportDailyBarsMinuteTimeframeRejected(t *testing.T) {
	// Verify the full CLI -> fake API -> typed error path when a minute timeframe
	// (minute_1 / minute_5) is sent to import_daily_bars. The server must return an
	// invalid_request ErrorInfo. When the expected error code matches the actual,
	// the CLI surfaces error.code=invalid_request with status=ok (expected typed
	// error reached).
	for _, tf := range []string{"minute_1", "minute_5"} {
		t.Run(tf, func(t *testing.T) {
			api := &fakeAPI{
				helloCaps: []string{"market-read"},
				importDailyBarsResp: &altv1.ImportDailyBarsResponse{
					Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "import_daily_bars supports timeframe daily; got " + tf},
				},
			}
			url := startFakeAPIServer(t, api)
			sc := &Scenario{
				Name: "import_minute_" + tf,
				Steps: []Step{{
					ID:     "import1",
					Action: ActionImportDailyBars,
					Request: Request{
						Provider:     "kis",
						SelectorKind: "watchlist",
						Symbols:      []string{"005930"},
						Timeframe:    tf,
					},
					Expect: Expect{
						Status:    "error",
						ErrorCode: "invalid_request",
					},
				}},
			}

			out, code := runScenario(t, sc, url)
			if code != codeOK {
				t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
			}
			// When expected ErrorCode matches, the output contains error.code=invalid_request
			// with status=ok (expected typed error reached, not a mismatch).
			if !strings.Contains(out, "error.code=invalid_request") {
				t.Errorf("output %q missing error.code=invalid_request for timeframe=%q", out, tf)
			}
			if !strings.Contains(out, tf) {
				t.Errorf("output %q missing timeframe %q", out, tf)
			}

			// Direct assertion: verify the fake API received the import request with correct Timeframe field.
			req := api.lastImportDailyBarsReq()
			if req == nil {
				t.Fatalf("server did not receive an import_daily_bars request for timeframe=%q", tf)
			}
			var wantTimeframe altv1.Timeframe
			if tf == "minute_1" {
				wantTimeframe = altv1.Timeframe_TIMEFRAME_MINUTE_1
			} else if tf == "minute_5" {
				wantTimeframe = altv1.Timeframe_TIMEFRAME_MINUTE_5
			} else {
				t.Fatalf("unexpected timeframe value: %q", tf)
			}
			if req.GetTimeframe() != wantTimeframe {
				t.Errorf("request Timeframe = %v, want %v for step timeframe=%q", req.GetTimeframe(), wantTimeframe, tf)
			}
		})
	}
}

func TestRunScenarioImportDailyBarsTimeframeMapping(t *testing.T) {
	// Test that runner correctly maps timeframe field for import_daily_bars.
	// The runner logic: if step.Request.Timeframe != "" use timeframeByName[tf]
	// else default to TIMEFRAME_DAILY. This test verifies the timeframeByName
	// map contents and the defaulting behavior.

	// Verify explicit timeframes map correctly in timeframeByName.
	for tfStr, wantTF := range map[string]altv1.Timeframe{
		"daily":    altv1.Timeframe_TIMEFRAME_DAILY,
		"minute_1": altv1.Timeframe_TIMEFRAME_MINUTE_1,
		"minute_5": altv1.Timeframe_TIMEFRAME_MINUTE_5,
		"monthly":  altv1.Timeframe_TIMEFRAME_MONTHLY,
	} {
		if got := timeframeByName[tfStr]; got != wantTF {
			t.Errorf("timeframeByName[%q] = %v, want %v", tfStr, got, wantTF)
		}
	}

	// Verify the runner import mapping logic (matches runner.go:257-262).
	tests := []struct {
		name      string
		timeframe string
		wantProto altv1.Timeframe
	}{
		{
			name:      "empty defaults to daily",
			timeframe: "",
			wantProto: altv1.Timeframe_TIMEFRAME_DAILY,
		},
		{
			name:      "daily explicit",
			timeframe: "daily",
			wantProto: altv1.Timeframe_TIMEFRAME_DAILY,
		},
		{
			name:      "minute_1",
			timeframe: "minute_1",
			wantProto: altv1.Timeframe_TIMEFRAME_MINUTE_1,
		},
		{
			name:      "minute_5",
			timeframe: "minute_5",
			wantProto: altv1.Timeframe_TIMEFRAME_MINUTE_5,
		},
	}

	// Simulate the runner's importTimeframe mapping logic.
	importTimeframe := func(stepTimeframe string) altv1.Timeframe {
		if stepTimeframe != "" {
			return timeframeByName[stepTimeframe]
		}
		return altv1.Timeframe_TIMEFRAME_DAILY
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := importTimeframe(tt.timeframe)
			if got != tt.wantProto {
				t.Errorf("importTimeframe(%q) = %v, want %v", tt.timeframe, got, tt.wantProto)
			}
		})
	}
}

func TestRunCollectionFreshnessBucketsByTimeframe(t *testing.T) {
	cases := []struct {
		name                 string
		timeframe            string
		expectedBuckets      []string
		barsFunc             func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error)
		expectGapStatus      string
		expectGapCount       int
		expectDuplicateCount int
		expectDelayDays      int
		expectLatestBucket   string
	}{
		{
			name:            "monthly_gap",
			timeframe:       "monthly",
			expectedBuckets: []string{"202501", "202502", "202503"},
			barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
				// 2025-01-15 (1736908800000), 2025-03-15 (1742006400000)
				// 202502 is a gap
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1736908800000},
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1742006400000},
					},
				}, nil
			},
			expectGapStatus:      "gap",
			expectGapCount:       2, // 2 symbols * 1 gap bucket = 2
			expectDuplicateCount: 0,
			expectDelayDays:      0,
			expectLatestBucket:   "202503",
		},
		{
			name:            "minute_duplicate",
			timeframe:       "minute_5",
			expectedBuckets: []string{"202501010900", "202501010905"},
			barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
				// 202501010900 (1735722000000), 202501010905 (1735722300000) duplicate for 005930
				if req.GetInstrumentId() == "KRX:005930" {
					return &altv1.ListBarsResponse{
						Bars: []*altv1.Bar{
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722000000},
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722300000},
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722300000},
						},
					}, nil
				}
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722000000},
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722300000},
					},
				}, nil
			},
			expectGapStatus:      "duplicate",
			expectGapCount:       0,
			expectDuplicateCount: 1,
			expectDelayDays:      0,
			expectLatestBucket:   "202501010905",
		},
		{
			name:            "minute_delay",
			timeframe:       "minute_1",
			expectedBuckets: []string{"202501010900", "202501010901", "202501010902"},
			barsFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
				// latest is 202501010901, missing 0902
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722000000},
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722060000},
					},
				}, nil
			},
			expectGapStatus:      "delayed",
			expectGapCount:       0,
			expectDuplicateCount: 0,
			// delay is calculated by index difference for the timeframe.
			// index difference is (expected - 1) - latestIdx = 2 - 1 = 1
			expectDelayDays:    1,
			expectLatestBucket: "202501010901",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			api := &fakeAPI{
				helloCaps: []string{"market-read"},
				instResp: &altv1.ListInstrumentsResponse{
					Instruments: []*altv1.Instrument{
						{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
						{Id: "KRX:000660", Symbol: "000660", Market: altv1.Market_MARKET_KR},
					},
				},
				barsRespFunc: tc.barsFunc,
			}
			url := startFakeAPIServer(t, api)
			sc := &Scenario{
				Name: "collection_freshness_timeframe_test",
				Universes: []UniverseConfig{{
					Name:         "kr-core",
					Provider:     "kis",
					SelectorKind: "watchlist",
					Market:       "kr",
					Venue:        "krx",
					Symbols:      []string{"005930", "000660"},
				}},
				Steps: []Step{{
					ID:     "freshness1",
					Action: ActionCollectionFreshness,
					Request: Request{
						Universe:        "kr-core",
						Timeframe:       tc.timeframe,
						FromUnixMs:      1735689600000,
						ToUnixMs:        1743465600000,
						ExpectedBuckets: tc.expectedBuckets,
					},
					Expect: Expect{
						Status:            "ok",
						GapStatus:         tc.expectGapStatus,
						GapCount:          intPtr(tc.expectGapCount),
						DuplicateCount:    intPtr(tc.expectDuplicateCount),
						ProviderDelayDays: intPtr(tc.expectDelayDays),
						LatestBucket:      tc.expectLatestBucket,
					},
				}},
			}
			if err := sc.Validate(); err != nil {
				t.Fatalf("scenario validate: %v", err)
			}

			out, code := runScenario(t, sc, url)
			if code != codeOK {
				t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
			}

			if !strings.Contains(out, "gap_status="+tc.expectGapStatus) {
				t.Errorf("output %q missing gap_status=%s", out, tc.expectGapStatus)
			}
			if !strings.Contains(out, "gap_count="+itoa(tc.expectGapCount)) {
				t.Errorf("output %q missing gap_count=%d", out, tc.expectGapCount)
			}
			if !strings.Contains(out, "duplicate_count="+itoa(tc.expectDuplicateCount)) {
				t.Errorf("output %q missing duplicate_count=%d", out, tc.expectDuplicateCount)
			}
			if !strings.Contains(out, "provider_delay_days="+itoa(tc.expectDelayDays)) {
				t.Errorf("output %q missing provider_delay_days=%d", out, tc.expectDelayDays)
			}
			if !strings.Contains(out, "latest_bucket="+tc.expectLatestBucket) {
				t.Errorf("output %q missing latest_bucket=%s", out, tc.expectLatestBucket)
			}
			if !strings.Contains(out, "timeframe="+tc.timeframe) {
				t.Errorf("output %q missing timeframe=%s", out, tc.timeframe)
			}
		})
	}
}

func TestMultiTimeframeFreshnessReadinessFixture(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"market-read"},
		instResp: &altv1.ListInstrumentsResponse{
			Instruments: []*altv1.Instrument{
				{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
				{Id: "KRX:000660", Symbol: "000660", Market: altv1.Market_MARKET_KR},
			},
		},
		barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
			if req.Timeframe == altv1.Timeframe_TIMEFRAME_MONTHLY {
				// 2025-01-15 (1736908800000), 2025-03-15 (1742006400000)
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1736908800000},
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1742006400000},
					},
				}, nil
			} else if req.Timeframe == altv1.Timeframe_TIMEFRAME_DAILY {
				// 2025-01-01 (1735689600000), 2025-01-02 (1735776000000), 2025-01-03 (1735862400000)
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735689600000},
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735776000000},
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735862400000},
					},
				}, nil
			} else if req.Timeframe == altv1.Timeframe_TIMEFRAME_MINUTE_1 {
				// 202501010900 (1735722000000), 202501010901 (1735722060000)
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722000000},
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722060000},
					},
				}, nil
			} else if req.Timeframe == altv1.Timeframe_TIMEFRAME_MINUTE_5 {
				// 202501010900 (1735722000000), 202501010905 (1735722300000) duplicate for 005930
				if req.GetInstrumentId() == "KRX:005930" {
					return &altv1.ListBarsResponse{
						Bars: []*altv1.Bar{
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722000000},
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722300000},
							{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722300000},
						},
					}, nil
				}
				return &altv1.ListBarsResponse{
					Bars: []*altv1.Bar{
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722000000},
						{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735722300000},
					},
				}, nil
			}
			return &altv1.ListBarsResponse{}, nil
		},
	}

	url := startFakeAPIServer(t, api)
	sc, err := LoadScenario(fixture(t, "multi_timeframe_freshness_readiness.yaml"))
	if err != nil {
		t.Fatalf("load scenario: %v", err)
	}

	var stdout bytes.Buffer
	code := RunScenario(context.Background(), sc, RunOptions{
		APIURL:  url,
		Timeout: 2 * time.Second,
		Format:  OutputJSONL,
	}, &stdout)

	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, stdout.String())
	}

	// Compare with expected jsonl
	expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "multi_timeframe_freshness_readiness.jsonl")
	expectedBytes, err := os.ReadFile(expectedPath)
	if err != nil {
		t.Fatalf("read expected jsonl: %v", err)
	}

	got := strings.TrimSpace(stdout.String())
	want := strings.TrimSpace(string(expectedBytes))

	// Normalize CRLF to LF
	got = strings.ReplaceAll(got, "\r\n", "\n")
	want = strings.ReplaceAll(want, "\r\n", "\n")

	if got != want {
		t.Errorf("JSONL output mismatch.\nGot:\n%s\n\nWant:\n%s", got, want)
	}
}

func TestRunCollectionFreshnessLatestBucketExpectationMismatch(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"market-read"},
		instResp: &altv1.ListInstrumentsResponse{
			Instruments: []*altv1.Instrument{
				{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
			},
		},
		barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
			return &altv1.ListBarsResponse{
				Bars: []*altv1.Bar{
					{InstrumentId: req.GetInstrumentId(), TimestampUnixMs: 1735689600000}, // 20250101 -> bucket "202501"
				},
			}, nil
		},
	}
	url := startFakeAPIServer(t, api)
	sc := &Scenario{
		Name: "latest_bucket_mismatch_test",
		Universes: []UniverseConfig{{
			Name:         "kr-core",
			Provider:     "kis",
			SelectorKind: "watchlist",
			Market:       "kr",
			Venue:        "krx",
			Symbols:      []string{"005930"},
		}},
		Steps: []Step{{
			ID:     "freshness1",
			Action: ActionCollectionFreshness,
			Request: Request{
				Universe:        "kr-core",
				Timeframe:       "monthly",
				FromUnixMs:      1735689600000,
				ToUnixMs:        1743465600000,
				ExpectedBuckets: []string{"202501"},
			},
			Expect: Expect{
				Status:       "ok",
				LatestBucket: "202502", // mismatch! (actual: "202501")
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}

	out, code := runScenario(t, sc, url)
	if code == codeOK {
		t.Fatalf("expected scenario to fail due to latest_bucket mismatch, but got success (code=%d, out=%q)", code, out)
	}
	if !strings.Contains(out, "expected latest_bucket") || !strings.Contains(out, "202502") || !strings.Contains(out, "202501") {
		t.Errorf("output %q missing expected mismatch message", out)
	}
}

func TestRunCollectionFreshnessNoLatestBucketDelayByTimeframe(t *testing.T) {
	cases := []struct {
		name            string
		timeframe       string
		expectedBuckets []string
		expectDelayDays int
	}{
		{
			name:            "monthly_no_bars",
			timeframe:       "monthly",
			expectedBuckets: []string{"202501", "202502", "202503"},
			expectDelayDays: 2, // 3 buckets -> delay = 2
		},
		{
			name:            "minute_no_bars",
			timeframe:       "minute_1",
			expectedBuckets: []string{"202501010900", "202501010901", "202501010902", "202501010903"},
			expectDelayDays: 3, // 4 buckets -> delay = 3
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			api := &fakeAPI{
				helloCaps: []string{"market-read"},
				instResp: &altv1.ListInstrumentsResponse{
					Instruments: []*altv1.Instrument{
						{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
					},
				},
				barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
					return &altv1.ListBarsResponse{Bars: []*altv1.Bar{}}, nil
				},
			}
			url := startFakeAPIServer(t, api)
			sc := &Scenario{
				Name: "no_bars_delay_test",
				Universes: []UniverseConfig{{
					Name:         "kr-core",
					Provider:     "kis",
					SelectorKind: "watchlist",
					Market:       "kr",
					Venue:        "krx",
					Symbols:      []string{"005930"},
				}},
				Steps: []Step{{
					ID:     "freshness1",
					Action: ActionCollectionFreshness,
					Request: Request{
						Universe:        "kr-core",
						Timeframe:       tc.timeframe,
						FromUnixMs:      1735689600000,
						ToUnixMs:        1743465600000,
						ExpectedBuckets: tc.expectedBuckets,
					},
					Expect: Expect{
						Status:            "ok",
						ProviderDelayDays: intPtr(tc.expectDelayDays),
					},
				}},
			}
			if err := sc.Validate(); err != nil {
				t.Fatalf("scenario validate: %v", err)
			}

			out, code := runScenario(t, sc, url)
			if code != codeOK {
				t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
			}
			if !strings.Contains(out, "provider_delay_days="+itoa(tc.expectDelayDays)) {
				t.Errorf("output %q missing provider_delay_days=%d", out, tc.expectDelayDays)
			}
		})
	}
}

func TestRunScenarioRefreshStatusSuccess(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"scheduler"},
		schedulerRefreshStatusResp: &altv1.SchedulerRefreshStatusResponse{
			Entries: []*altv1.SchedulerRefreshStatusEntry{
				{
					Schedule:          "kr-daily",
					Status:            "success",
					LastSuccessUnixMs: 1700000000000,
					NextRunUnixMs:     1700086400000,
					ImportedBarCount:  120,
					MissingCount:      0,
					GapCount:          0,
					DuplicateCount:    0,
					ProviderDelayDays: 1,
				},
				{
					Schedule:          "us-daily",
					Status:            "stale",
					LastSuccessUnixMs: 1699000000000,
					NextRunUnixMs:     1700086400000,
					ImportedBarCount:  0,
					MissingCount:      2,
					GapCount:          1,
					DuplicateCount:    0,
					ProviderDelayDays: 0,
				},
				{
					Schedule:          "kr-minute",
					Status:            "error",
					LastSuccessUnixMs: 1698000000000,
					NextRunUnixMs:     0,
					ImportedBarCount:  0,
					MissingCount:      0,
					GapCount:          0,
					DuplicateCount:    0,
					ProviderDelayDays: 0,
					LastError:         "connection timeout to KIS API",
				},
			},
		},
	}
	url := startFakeAPIServer(t, api)
	sc := &Scenario{
		Name: "refresh_status_success",
		Steps: []Step{{
			ID:     "status1",
			Action: ActionSchedulerRefreshStatus,
			Request: Request{
				ScheduleName: "kr-daily",
			},
			Expect: Expect{
				Status:          "ok",
				SchedulerStatus: "success",
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}
	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	if !strings.Contains(out, "scheduler_status=success") {
		t.Errorf("output %q missing scheduler_status=success", out)
	}
	if !strings.Contains(out, "imported_bar_count=120") {
		t.Errorf("output %q missing imported_bar_count=120", out)
	}
	if !strings.Contains(out, "provider_delay_days=1") {
		t.Errorf("output %q missing provider_delay_days=1", out)
	}
	req := api.lastSchedulerRefreshStatusReq()
	if req == nil {
		t.Fatal("no request received by fake server")
	}
	if req.GetScheduleName() != "kr-daily" {
		t.Errorf("schedule_name = %q, want %q", req.GetScheduleName(), "kr-daily")
	}
}

func TestRunScenarioRefreshStatusExpectedStatusMismatch(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"scheduler"},
		schedulerRefreshStatusResp: &altv1.SchedulerRefreshStatusResponse{
			Entries: []*altv1.SchedulerRefreshStatusEntry{
				{Schedule: "kr-daily", Status: "stale"},
			},
		},
	}
	url := startFakeAPIServer(t, api)
	sc := &Scenario{
		Name: "refresh_status_mismatch",
		Steps: []Step{{
			ID:     "status1",
			Action: ActionSchedulerRefreshStatus,
			Expect: Expect{
				Status:          "ok",
				SchedulerStatus: "success",
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}
	out, code := runScenario(t, sc, url)
	if code == codeOK {
		t.Fatalf("expected non-zero exit code, got 0 (out=%q)", out)
	}
	if !strings.Contains(out, "mismatch") {
		t.Errorf("output %q missing mismatch status", out)
	}
}

func TestRunScenarioRefreshStatusMinImportedBarCountMismatch(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"scheduler"},
		schedulerRefreshStatusResp: &altv1.SchedulerRefreshStatusResponse{
			Entries: []*altv1.SchedulerRefreshStatusEntry{
				{Schedule: "kr-daily", Status: "success", ImportedBarCount: 0},
			},
		},
	}
	url := startFakeAPIServer(t, api)
	minBars := 1
	sc := &Scenario{
		Name: "refresh_status_min_bars_mismatch",
		Steps: []Step{{
			ID:     "status1",
			Action: ActionSchedulerRefreshStatus,
			Request: Request{
				ScheduleName: "kr-daily",
			},
			Expect: Expect{
				Status:              "ok",
				SchedulerStatus:     "success",
				MinImportedBarCount: &minBars,
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}
	out, code := runScenario(t, sc, url)
	if code == codeOK {
		t.Fatalf("expected non-zero exit code for empty refresh, got 0 (out=%q)", out)
	}
	if !strings.Contains(out, "mismatch") {
		t.Errorf("output %q missing mismatch status", out)
	}
}

func TestRunScenarioRefreshStatusMinImportedBarCountSatisfied(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"scheduler"},
		schedulerRefreshStatusResp: &altv1.SchedulerRefreshStatusResponse{
			Entries: []*altv1.SchedulerRefreshStatusEntry{
				{Schedule: "kr-daily", Status: "success", ImportedBarCount: 120},
			},
		},
	}
	url := startFakeAPIServer(t, api)
	minBars := 1
	sc := &Scenario{
		Name: "refresh_status_min_bars_ok",
		Steps: []Step{{
			ID:     "status1",
			Action: ActionSchedulerRefreshStatus,
			Request: Request{
				ScheduleName: "kr-daily",
			},
			Expect: Expect{
				Status:              "ok",
				SchedulerStatus:     "success",
				MinImportedBarCount: &minBars,
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}
	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	if !strings.Contains(out, "imported_bar_count=120") {
		t.Errorf("output %q missing imported_bar_count=120", out)
	}
}

func TestRunScenarioRefreshStatusTypedError(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"scheduler"},
		schedulerRefreshStatusResp: &altv1.SchedulerRefreshStatusResponse{
			Error: &altv1.ErrorInfo{Code: "unavailable", Message: "scheduler not running"},
		},
	}
	url := startFakeAPIServer(t, api)
	sc := &Scenario{
		Name: "refresh_status_error",
		Steps: []Step{{
			ID:     "status1",
			Action: ActionSchedulerRefreshStatus,
			Expect: Expect{
				Status:    "error",
				ErrorCode: "unavailable",
			},
		}},
	}
	if err := sc.Validate(); err != nil {
		t.Fatalf("scenario validate: %v", err)
	}
	out, code := runScenario(t, sc, url)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
	}
	if !strings.Contains(out, "status=ok") {
		t.Errorf("output %q missing status=ok for expected typed error", out)
	}
}

func TestRunScenarioRefreshStatusFixture(t *testing.T) {
	api := &fakeAPI{
		helloCaps: []string{"scheduler"},
	}
	// Set per-schedule-name responses using the function field.
	api.schedulerRefreshStatusFunc = func(name string) *altv1.SchedulerRefreshStatusResponse {
		switch name {
		case "kr-daily":
			return &altv1.SchedulerRefreshStatusResponse{
				Entries: []*altv1.SchedulerRefreshStatusEntry{
					{
						Schedule:          "kr-daily",
						Status:            "success",
						LastSuccessUnixMs: 1700000000000,
						NextRunUnixMs:     1700086400000,
						ImportedBarCount:  120,
						MissingCount:      0,
						GapCount:          0,
						DuplicateCount:    0,
						ProviderDelayDays: 1,
					},
				},
			}
		case "us-daily":
			return &altv1.SchedulerRefreshStatusResponse{
				Entries: []*altv1.SchedulerRefreshStatusEntry{
					{
						Schedule:          "us-daily",
						Status:            "stale",
						LastSuccessUnixMs: 1699000000000,
						NextRunUnixMs:     1700086400000,
						ImportedBarCount:  0,
						MissingCount:      2,
						GapCount:          1,
						DuplicateCount:    0,
						ProviderDelayDays: 0,
					},
				},
			}
		case "kr-minute":
			return &altv1.SchedulerRefreshStatusResponse{
				Entries: []*altv1.SchedulerRefreshStatusEntry{
					{
						Schedule:          "kr-minute",
						Status:            "error",
						LastSuccessUnixMs: 1698000000000,
						NextRunUnixMs:     0,
						ImportedBarCount:  0,
						MissingCount:      0,
						GapCount:          0,
						DuplicateCount:    0,
						ProviderDelayDays: 0,
						LastError:         "connection timeout to KIS API",
					},
				},
			}
		default:
			return nil // should not happen with known schedule names
		}
	}
	url := startFakeAPIServer(t, api)
	sc, err := LoadScenario(fixture(t, "scheduler_refresh_status.yaml"))
	if err != nil {
		t.Fatalf("load scenario: %v", err)
	}

	var stdout bytes.Buffer
	code := RunScenario(context.Background(), sc, RunOptions{
		APIURL:  url,
		Timeout: 2 * time.Second,
		Format:  OutputJSONL,
	}, &stdout)
	if code != codeOK {
		t.Fatalf("exit code = %d, want 0 (out=%q)", code, stdout.String())
	}

	expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "scheduler_refresh_status.jsonl")
	expectedBytes, err := os.ReadFile(expectedPath)
	if err != nil {
		t.Fatalf("read expected jsonl: %v", err)
	}

	got := strings.TrimSpace(stdout.String())
	want := strings.TrimSpace(string(expectedBytes))
	got = strings.ReplaceAll(got, "\r\n", "\n")
	want = strings.ReplaceAll(want, "\r\n", "\n")

	if got != want {
		t.Errorf("JSONL output mismatch.\nGot:\n%s\n\nWant:\n%s", got, want)
	}
}
