package operator

import (
	"fmt"
	"path/filepath"
	"strings"
	"testing"
)

func fixture(t *testing.T, name string) string {
	t.Helper()
	return filepath.Join("..", "..", "testdata", "operator", name)
}

func TestLoadScenarioValidatesExample(t *testing.T) {
	sc, err := LoadScenario(fixture(t, "api_connection_smoke.yaml"))
	if err != nil {
		t.Fatalf("LoadScenario returned error: %v", err)
	}
	if sc.Name != "api_connection_smoke" {
		t.Errorf("name = %q, want api_connection_smoke", sc.Name)
	}
	if len(sc.Steps) != 1 {
		t.Fatalf("steps = %d, want 1", len(sc.Steps))
	}
	if sc.Steps[0].Action != ActionHello {
		t.Errorf("step action = %q, want %q", sc.Steps[0].Action, ActionHello)
	}
}

func TestLoadScenarioRejectsUnknownAction(t *testing.T) {
	data := []byte("name: unknown_action\ntimeout: 1s\nsteps:\n  - id: unknown\n    action: send_unsupported_request\n    expect:\n      status: error\n")
	_, err := ParseScenario(data)
	if err == nil {
		t.Fatal("expected error for unknown action, got nil")
	}
	if !strings.Contains(err.Error(), "unknown action") {
		t.Errorf("error = %v, want it to mention unknown action", err)
	}
}

func TestLoadScenarioRejectsMissingStepID(t *testing.T) {
	data := []byte("name: missing_id\ntimeout: 1s\nsteps:\n  - action: hello\n")
	_, err := ParseScenario(data)
	if err == nil {
		t.Fatal("expected error for missing step id, got nil")
	}
	if !strings.Contains(err.Error(), "id is required") {
		t.Errorf("error = %v, want it to mention id is required", err)
	}
}

func TestParseScenarioRejectsInvalidDuration(t *testing.T) {
	data := []byte("name: invalid_timeout\ntimeout: not-a-duration\nsteps:\n  - id: hello\n    action: hello\n")
	_, err := ParseScenario(data)
	if err == nil {
		t.Fatal("expected error for invalid timeout, got nil")
	}
	if !strings.Contains(err.Error(), "invalid timeout") {
		t.Errorf("error = %v, want it to mention invalid timeout", err)
	}
}

func TestValidateRejectsUnknownExpectStatus(t *testing.T) {
	data := []byte("name: bad_expect\ntimeout: 1s\nsteps:\n  - id: hello\n    action: hello\n    expect:\n      status: maybe\n")
	_, err := ParseScenario(data)
	if err == nil {
		t.Fatal("expected error for unknown expect.status, got nil")
	}
	if !strings.Contains(err.Error(), "expect.status") {
		t.Errorf("error = %v, want it to mention expect.status", err)
	}
}

func TestValidateRejectsUnknownFreshnessStatus(t *testing.T) {
	data := []byte("name: bad_freshness_status\ntimeout: 1s\nsteps:\n  - id: hello\n    action: hello\n    expect:\n      freshness_status: invalid_status\n")
	_, err := ParseScenario(data)
	if err == nil {
		t.Fatal("expected error for unknown expect.freshness_status, got nil")
	}
	if !strings.Contains(err.Error(), "expect.freshness_status") {
		t.Errorf("error = %v, want it to mention expect.freshness_status", err)
	}
}

func TestValidateRejectsNegativeMissingCount(t *testing.T) {
	data := []byte("name: bad_missing_count\ntimeout: 1s\nsteps:\n  - id: hello\n    action: hello\n    expect:\n      missing_count: -1\n")
	_, err := ParseScenario(data)
	if err == nil {
		t.Fatal("expected error for negative expect.missing_count, got nil")
	}
	if !strings.Contains(err.Error(), "expect.missing_count") {
		t.Errorf("error = %v, want it to mention expect.missing_count", err)
	}
}

func TestValidateRejectsErrorCodeWithoutErrorStatus(t *testing.T) {
	data := []byte("name: bad_code\ntimeout: 1s\nsteps:\n  - id: hello\n    action: hello\n    expect:\n      status: ok\n      error_code: invalid_request\n")
	_, err := ParseScenario(data)
	if err == nil {
		t.Fatal("expected error for error_code without error status, got nil")
	}
	if !strings.Contains(err.Error(), "error_code") {
		t.Errorf("error = %v, want it to mention error_code", err)
	}
}
func TestInvalidRequestMatrixDryRunCoverage(t *testing.T) {
	tests := []struct {
		name        string
		yaml        string
		expectedErr string
	}{
		{
			name: "missing_run_id",
			yaml: `
name: missing_run_id
timeout: 1s
steps:
  - id: step1
    action: get_backtest_result
    request: {}
`,
			expectedErr: "requires request.run_id",
		},
		{
			name: "missing_strategy_id",
			yaml: `
name: missing_strategy_id
timeout: 1s
steps:
  - id: step1
    action: start_backtest
    request:
      market: kr
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
`,
			expectedErr: "requires request.strategy_id",
		},
		{
			name: "empty_compare_id",
			yaml: `
name: empty_compare_id
timeout: 1s
steps:
  - id: step1
    action: compare_backtest_runs
    request:
      run_ids: [""]
`,
			expectedErr: "cannot contain empty or blank values",
		},
		{
			name: "invalid_market",
			yaml: `
name: invalid_market
timeout: 1s
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      market: invalid_market
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
`,
			expectedErr: "unsupported market",
		},
		{
			name: "invalid_timeframe",
			yaml: `
name: invalid_timeframe
timeout: 1s
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      market: kr
      timeframe: invalid_timeframe
      from_unix_ms: 100
      to_unix_ms: 200
`,
			expectedErr: "unsupported timeframe",
		},
		{
			name: "invalid_date_range",
			yaml: `
name: invalid_date_range
timeout: 1s
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      market: kr
      timeframe: daily
      from_unix_ms: 200
      to_unix_ms: 100
`,
			expectedErr: "cannot be after request.to_unix_ms",
		},
		{
			name: "start_backtest_blank_symbol",
			yaml: `
name: start_backtest_blank_symbol
timeout: 1s
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      market: kr
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
      symbols: [" "]
`,
			expectedErr: "start_backtest request.symbols cannot contain empty or blank values",
		},
		{
			name: "start_backtest_blank_instrument_id",
			yaml: `
name: start_backtest_blank_instrument_id
timeout: 1s
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      market: kr
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
      instrument_ids: [""]
`,
			expectedErr: "start_backtest request.instrument_ids cannot contain empty or blank values",
		},
		{
			name: "start_backtest_universe_inline_conflict",
			yaml: `
name: start_backtest_universe_inline_conflict
timeout: 1s
universes:
  - name: kr-backtest-smoke
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      universe: kr-backtest-smoke
      symbols: ["005930"]
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
`,
			expectedErr: "cannot specify both universe reference",
		},
		{
			name: "import_missing_provider",
			yaml: `
name: import_missing_provider
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      selector_kind: watchlist
      symbols: ["005930"]
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "requires request.provider",
		},
		{
			name: "import_invalid_provider",
			yaml: `
name: import_invalid_provider
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: invalid
      selector_kind: watchlist
      symbols: ["005930"]
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "unsupported provider",
		},
		{
			name: "import_missing_selector_kind",
			yaml: `
name: import_missing_selector_kind
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      symbols: ["005930"]
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "requires request.selector_kind",
		},
		{
			name: "import_invalid_selector_kind",
			yaml: `
name: import_invalid_selector_kind
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: invalid
      symbols: ["005930"]
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "unsupported selector_kind",
		},
		{
			name: "import_missing_symbols",
			yaml: `
name: import_missing_symbols
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "requires request.symbols",
		},
		{
			name: "import_empty_symbol",
			yaml: `
name: import_empty_symbol
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      symbols: ["005930", " "]
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "symbols cannot contain empty or blank values",
		},
		{
			name: "import_invalid_market",
			yaml: `
name: import_invalid_market
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      symbols: ["005930"]
      market: invalid
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "unsupported market",
		},
		{
			name: "import_invalid_venue",
			yaml: `
name: import_invalid_venue
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      symbols: ["005930"]
      venue: invalid
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "unsupported venue",
		},
		{
			name: "import_invalid_from_yyyymmdd",
			yaml: `
name: import_invalid_from_yyyymmdd
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      symbols: ["005930"]
      from_yyyymmdd: "2024-05"
      to_yyyymmdd: "20240528"
`,
			expectedErr: "must be in YYYYMMDD format",
		},
		{
			name: "import_invalid_to_yyyymmdd",
			yaml: `
name: import_invalid_to_yyyymmdd
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      symbols: ["005930"]
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240532"
`,
			expectedErr: "must be in YYYYMMDD format",
		},
		{
			name: "import_reversed_dates",
			yaml: `
name: import_reversed_dates
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      symbols: ["005930"]
      from_yyyymmdd: "20240528"
      to_yyyymmdd: "20240527"
`,
			expectedErr: "cannot be after request.to_yyyymmdd",
		},
		{
			name: "import_missing_from_yyyymmdd",
			yaml: `
name: import_missing_from_yyyymmdd
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      symbols: ["005930"]
      to_yyyymmdd: "20240528"
`,
			expectedErr: "requires request.from_yyyymmdd",
		},
		{
			name: "import_missing_to_yyyymmdd",
			yaml: `
name: import_missing_to_yyyymmdd
timeout: 1s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      symbols: ["005930"]
      from_yyyymmdd: "20240527"
`,
			expectedErr: "requires request.to_yyyymmdd",
		},
		{
			name: "freshness_missing_universe",
			yaml: `
name: freshness_missing_universe
timeout: 1s
steps:
  - id: step1
    action: collection_freshness
    request:
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
`,
			expectedErr: "requires request.universe",
		},
		{
			name: "freshness_invalid_timeframe",
			yaml: `
name: freshness_invalid_timeframe
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: invalid
      from_unix_ms: 100
      to_unix_ms: 200
`,
			expectedErr: "unsupported timeframe",
		},
		{
			name: "freshness_missing_unix_ms",
			yaml: `
name: freshness_missing_unix_ms
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: daily
      from_unix_ms: 100
`,
			expectedErr: "requires request.from_unix_ms and request.to_unix_ms",
		},
		{
			name: "freshness_reversed_dates",
			yaml: `
name: freshness_reversed_dates
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: daily
      from_unix_ms: 200
      to_unix_ms: 100
`,
			expectedErr: "cannot be after request.to_unix_ms",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			_, err := ParseScenario([]byte(tt.yaml))
			if err == nil {
				t.Fatalf("expected error containing %q, got nil", tt.expectedErr)
			}
			if !strings.Contains(err.Error(), tt.expectedErr) {
				t.Errorf("error = %v, want it to contain %q", err, tt.expectedErr)
			}
		})
	}
}

func TestValidateImportDailyBars(t *testing.T) {
	yamlText := `
name: valid_import
timeout: 5s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      market: kr
      venue: krx
      symbols: ["005930"]
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
    expect:
      status: ok
`
	sc, err := ParseScenario([]byte(yamlText))
	if err != nil {
		t.Fatalf("unexpected error parsing valid import scenario: %v", err)
	}
	if len(sc.Steps) != 1 {
		t.Fatalf("steps = %d, want 1", len(sc.Steps))
	}
	step := sc.Steps[0]
	if step.Action != ActionImportDailyBars {
		t.Errorf("action = %q, want %q", step.Action, ActionImportDailyBars)
	}
	if step.Request.Provider != "kis" {
		t.Errorf("provider = %q, want kis", step.Request.Provider)
	}
	if step.Request.SelectorKind != "watchlist" {
		t.Errorf("selector_kind = %q, want watchlist", step.Request.SelectorKind)
	}
	if step.Request.Market != "kr" {
		t.Errorf("market = %q, want kr", step.Request.Market)
	}
	if step.Request.Venue != "krx" {
		t.Errorf("venue = %q, want krx", step.Request.Venue)
	}
	if len(step.Request.Symbols) != 1 || step.Request.Symbols[0] != "005930" {
		t.Errorf("symbols = %v, want [005930]", step.Request.Symbols)
	}
	if step.Request.FromYYYYMMDD != "20240527" {
		t.Errorf("from_yyyymmdd = %q, want 20240527", step.Request.FromYYYYMMDD)
	}
	if step.Request.ToYYYYMMDD != "20240528" {
		t.Errorf("to_yyyymmdd = %q, want 20240528", step.Request.ToYYYYMMDD)
	}
}

func TestValidateImportDailyBarsUSVenues(t *testing.T) {
	for _, venue := range []string{"nasdaq", "nyse"} {
		t.Run(venue, func(t *testing.T) {
			yamlText := fmt.Sprintf(`
name: valid_us_import
timeout: 5s
steps:
  - id: step1
    action: import_daily_bars
    request:
      provider: kis
      selector_kind: watchlist
      market: us
      venue: %s
      symbols: ["AAPL"]
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
    expect:
      status: ok
`, venue)
			sc, err := ParseScenario([]byte(yamlText))
			if err != nil {
				t.Fatalf("unexpected error parsing valid US import scenario: %v", err)
			}
			got := sc.Steps[0].Request.Venue
			if got != venue {
				t.Errorf("venue = %q, want %q", got, venue)
			}
		})
	}
}

func TestValidatePaperTradingScenario(t *testing.T) {
	yamlText := `
name: valid_paper
timeout: 5s
steps:
  - id: start
    action: start_paper_trading
    request:
      account_id: paper-1
      strategy_id: strategy-v1
      market: kr
      timeframe: daily
      from_unix_ms: 1746057600000
      to_unix_ms: 1747267200000
      starting_cash: "10000000"
    expect:
      status: ok
  - id: state
    action: get_paper_trading_state
    request:
      account_id: paper-1
    expect:
      status: ok
`
	sc, err := ParseScenario([]byte(yamlText))
	if err != nil {
		t.Fatalf("unexpected error parsing valid paper scenario: %v", err)
	}
	if len(sc.Steps) != 2 {
		t.Fatalf("steps = %d, want 2", len(sc.Steps))
	}
	if sc.Steps[0].Action != ActionStartPaperTrading {
		t.Errorf("action = %q, want %q", sc.Steps[0].Action, ActionStartPaperTrading)
	}
	if sc.Steps[0].Request.AccountID != "paper-1" {
		t.Errorf("account_id = %q, want paper-1", sc.Steps[0].Request.AccountID)
	}
	if sc.Steps[0].Request.StartingCash != "10000000" {
		t.Errorf("starting_cash = %q, want 10000000", sc.Steps[0].Request.StartingCash)
	}
}

func TestValidateLiveOrderScenario(t *testing.T) {
	yamlText := `
name: live_order_lifecycle
timeout: 10s
steps:
  - id: submit
    action: submit_live_order
    request:
      account_id: live-acct-1
      instrument_id: KRX:005930
      side: buy
      quantity: "1"
      order_type: kis_best_limit
      operator_confirmed: true
      operator_id: operator-1
    expect:
      status: ok
      live_order_status: submitted
  - id: cancel
    action: cancel_live_order
    request:
      account_id: live-acct-1
      live_order_id: "{{steps.submit.live_order.id}}"
    expect:
      status: ok
`
	sc, err := ParseScenario([]byte(yamlText))
	if err != nil {
		t.Fatalf("unexpected error parsing valid live order scenario: %v", err)
	}
	if len(sc.Steps) != 2 {
		t.Fatalf("steps = %d, want 2", len(sc.Steps))
	}
	if sc.Steps[0].Action != ActionSubmitLiveOrder {
		t.Errorf("step[0] action = %q, want %q", sc.Steps[0].Action, ActionSubmitLiveOrder)
	}
	if sc.Steps[0].Request.OrderType != "kis_best_limit" {
		t.Errorf("order_type = %q, want kis_best_limit (custom type must be preserved)", sc.Steps[0].Request.OrderType)
	}
	if !sc.Steps[0].Request.OperatorConfirmed {
		t.Error("operator_confirmed = false, want true")
	}
}

func TestValidateLiveOrderRejectsMissingFields(t *testing.T) {
	cases := map[string]string{
		"submit missing account_id": `
name: bad
steps:
  - id: submit
    action: submit_live_order
    request:
      instrument_id: KRX:005930
      side: buy
      quantity: "1"
      order_type: market
`,
		"submit missing instrument_id": `
name: bad
steps:
  - id: submit
    action: submit_live_order
    request:
      account_id: live-1
      side: buy
      quantity: "1"
      order_type: market
`,
		"submit invalid side": `
name: bad
steps:
  - id: submit
    action: submit_live_order
    request:
      account_id: live-1
      instrument_id: KRX:005930
      side: long
      quantity: "1"
      order_type: market
`,
		"submit missing quantity": `
name: bad
steps:
  - id: submit
    action: submit_live_order
    request:
      account_id: live-1
      instrument_id: KRX:005930
      side: buy
      order_type: market
`,
		"submit missing order_type": `
name: bad
steps:
  - id: submit
    action: submit_live_order
    request:
      account_id: live-1
      instrument_id: KRX:005930
      side: buy
      quantity: "1"
`,
		"cancel missing account_id": `
name: bad
steps:
  - id: cancel
    action: cancel_live_order
    request:
      live_order_id: lo-1
`,
		"cancel missing live_order_id": `
name: bad
steps:
  - id: cancel
    action: cancel_live_order
    request:
      account_id: live-1
`,
		"get missing account_id": `
name: bad
steps:
  - id: get
    action: get_live_order
    request:
      live_order_id: lo-1
`,
		"get missing live_order_id": `
name: bad
steps:
  - id: get
    action: get_live_order
    request:
      account_id: live-1
`,
	}
	for name, yamlText := range cases {
		t.Run(name, func(t *testing.T) {
			if _, err := ParseScenario([]byte(yamlText)); err == nil {
				t.Fatalf("expected validation error for %q", name)
			}
		})
	}
}

func TestValidatePaperTradingRejectsMissingFields(t *testing.T) {
	cases := map[string]string{
		"start missing account_id": `
name: bad
steps:
  - id: start
    action: start_paper_trading
    request:
      strategy_id: strategy-v1
      market: kr
      timeframe: daily
      from_unix_ms: 1
      to_unix_ms: 2
      starting_cash: "100"
`,
		"start missing starting_cash": `
name: bad
steps:
  - id: start
    action: start_paper_trading
    request:
      account_id: paper-1
      strategy_id: strategy-v1
      market: kr
      timeframe: daily
      from_unix_ms: 1
      to_unix_ms: 2
`,
		"state missing account_id": `
name: bad
steps:
  - id: state
    action: get_paper_trading_state
    request: {}
`,
	}
	for name, yamlText := range cases {
		t.Run(name, func(t *testing.T) {
			if _, err := ParseScenario([]byte(yamlText)); err == nil {
				t.Fatalf("expected validation error for %q", name)
			}
		})
	}
}

func TestValidateImportDailyBarsUniverseReference(t *testing.T) {
	yamlText := `
name: valid_universe_ref
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    market: kr
    venue: krx
    symbols: ["005930"]
steps:
  - id: step1
    action: import_daily_bars
    request:
      universe: kr-core
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
    expect:
      status: ok
`
	sc, err := ParseScenario([]byte(yamlText))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(sc.Steps) != 1 {
		t.Fatalf("steps = %d, want 1", len(sc.Steps))
	}
	step := sc.Steps[0]
	if step.Request.Provider != "kis" {
		t.Errorf("provider = %q, want kis", step.Request.Provider)
	}
	if step.Request.SelectorKind != "watchlist" {
		t.Errorf("selector_kind = %q, want watchlist", step.Request.SelectorKind)
	}
	if step.Request.Market != "kr" {
		t.Errorf("market = %q, want kr", step.Request.Market)
	}
	if step.Request.Venue != "krx" {
		t.Errorf("venue = %q, want krx", step.Request.Venue)
	}
	if len(step.Request.Symbols) != 1 || step.Request.Symbols[0] != "005930" {
		t.Errorf("symbols = %v, want [005930]", step.Request.Symbols)
	}
	if step.Request.Name != "kr-core" {
		t.Errorf("name = %q, want kr-core", step.Request.Name)
	}
}

func TestValidateRejectsDuplicateUniverseName(t *testing.T) {
	yamlText := `
name: dup_universe
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["000660"]
steps:
  - id: step1
    action: hello
`
	_, err := ParseScenario([]byte(yamlText))
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if !strings.Contains(err.Error(), "duplicate universe name") {
		t.Errorf("error = %v, want duplicate universe name", err)
	}
}

func TestValidateRejectsMissingUniverseReference(t *testing.T) {
	yamlText := `
name: missing_universe_ref
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: import_daily_bars
    request:
      universe: kr-other
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`
	_, err := ParseScenario([]byte(yamlText))
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if !strings.Contains(err.Error(), "referenced universe \"kr-other\" not found") {
		t.Errorf("error = %v, want referenced universe not found", err)
	}
}

func TestValidateRejectsUniverseInlineConflict(t *testing.T) {
	yamlText := `
name: inline_conflict
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: import_daily_bars
    request:
      universe: kr-core
      provider: kis
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
`
	_, err := ParseScenario([]byte(yamlText))
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if !strings.Contains(err.Error(), "cannot specify both universe reference") {
		t.Errorf("error = %v, want cannot specify both universe reference", err)
	}
}

func TestValidateImportDailyBarsUniverseReferenceIsIdempotent(t *testing.T) {
	yamlText := `
name: valid_universe_ref
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    market: kr
    venue: krx
    symbols: ["005930"]
steps:
  - id: step1
    action: import_daily_bars
    request:
      universe: kr-core
      from_yyyymmdd: "20240527"
      to_yyyymmdd: "20240528"
    expect:
      status: ok
`
	sc, err := ParseScenario([]byte(yamlText))
	if err != nil {
		t.Fatalf("unexpected error on ParseScenario: %v", err)
	}

	if err := sc.Validate(); err != nil {
		t.Errorf("unexpected error on second Validate call: %v", err)
	}
}

func TestValidateRejectsUnsupportedGapStatus(t *testing.T) {
	data := []byte("name: bad_gap_status\ntimeout: 1s\nsteps:\n  - id: hello\n    action: hello\n    expect:\n      gap_status: invalid_status\n")
	_, err := ParseScenario(data)
	if err == nil {
		t.Fatal("expected error for unknown expect.gap_status, got nil")
	}
	if !strings.Contains(err.Error(), "expect.gap_status") {
		t.Errorf("error = %v, want it to mention expect.gap_status", err)
	}
}

func TestValidateRejectsNegativeGapCounts(t *testing.T) {
	cases := []struct {
		name string
		yaml string
		err  string
	}{
		{
			name: "negative gap_count",
			yaml: "name: bad_gap_count\ntimeout: 1s\nsteps:\n  - id: hello\n    action: hello\n    expect:\n      gap_count: -1\n",
			err:  "expect.gap_count",
		},
		{
			name: "negative duplicate_count",
			yaml: "name: bad_duplicate_count\ntimeout: 1s\nsteps:\n  - id: hello\n    action: hello\n    expect:\n      duplicate_count: -1\n",
			err:  "expect.duplicate_count",
		},
		{
			name: "negative provider_delay_days",
			yaml: "name: bad_provider_delay\ntimeout: 1s\nsteps:\n  - id: hello\n    action: hello\n    expect:\n      provider_delay_days: -1\n",
			err:  "expect.provider_delay_days",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			_, err := ParseScenario([]byte(tc.yaml))
			if err == nil {
				t.Fatalf("expected error for %s, got nil", tc.name)
			}
			if !strings.Contains(err.Error(), tc.err) {
				t.Errorf("error = %v, want it to contain %q", err, tc.err)
			}
		})
	}
}

func TestValidateExpectedYYYYMMDD(t *testing.T) {
	cases := []struct {
		name        string
		yaml        string
		expectedErr string
	}{
		{
			name: "timeframe not daily",
			yaml: `
name: bad_timeframe
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: minute_1
      from_unix_ms: 1716768000000
      to_unix_ms: 1716854400000
      expected_yyyymmdd: ["20240527"]
`,
			expectedErr: "expected_yyyymmdd is only supported for timeframe \"daily\"",
		},
		{
			name: "malformed date",
			yaml: `
name: malformed_date
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: daily
      from_unix_ms: 1716768000000
      to_unix_ms: 1716854400000
      expected_yyyymmdd: ["2024-05-27"]
`,
			expectedErr: "must be in YYYYMMDD format",
		},
		{
			name: "unsorted dates",
			yaml: `
name: unsorted_dates
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: daily
      from_unix_ms: 1716768000000
      to_unix_ms: 1716854400000
      expected_yyyymmdd: ["20240528", "20240527"]
`,
			expectedErr: "must be in ascending order",
		},
		{
			name: "duplicate dates",
			yaml: `
name: duplicate_dates
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: daily
      from_unix_ms: 1716768000000
      to_unix_ms: 1716854400000
      expected_yyyymmdd: ["20240527", "20240527"]
`,
			expectedErr: "contains duplicate date",
		},
		{
			name: "date out of window before",
			yaml: `
name: out_of_window_before
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: daily
      from_unix_ms: 1716768000000 # 2024-05-27 00:00:00 UTC
      to_unix_ms: 1716854400000   # 2024-05-28 00:00:00 UTC
      expected_yyyymmdd: ["20240526"]
`,
			expectedErr: "is out of request window",
		},
		{
			name: "date out of window after",
			yaml: `
name: out_of_window_after
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: daily
      from_unix_ms: 1716768000000 # 2024-05-27 00:00:00 UTC
      to_unix_ms: 1716854400000   # 2024-05-28 00:00:00 UTC
      expected_yyyymmdd: ["20240529"]
`,
			expectedErr: "is out of request window",
		},
		{
			name: "valid expected_yyyymmdd and gap expectations",
			yaml: `
name: valid_gap_contract
timeout: 1s
universes:
  - name: kr-core
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: collection_freshness
    request:
      universe: kr-core
      timeframe: daily
      from_unix_ms: 1716768000000 # 2024-05-27 00:00:00 UTC
      to_unix_ms: 1716854400000   # 2024-05-28 00:00:00 UTC
      expected_yyyymmdd: ["20240527", "20240528"]
    expect:
      gap_status: clean
      gap_count: 0
      duplicate_count: 0
      provider_delay_days: 0
`,
			expectedErr: "",
		},
	}
	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			sc, err := ParseScenario([]byte(tt.yaml))
			if tt.expectedErr != "" {
				if err == nil {
					t.Fatalf("expected error containing %q, got nil", tt.expectedErr)
				}
				if !strings.Contains(err.Error(), tt.expectedErr) {
					t.Errorf("error = %v, want it to contain %q", err, tt.expectedErr)
				}
			} else {
				if err != nil {
					t.Fatalf("unexpected error: %v", err)
				}
				step := sc.Steps[0]
				if len(step.Request.ExpectedYYYYMMDD) != 2 {
					t.Errorf("expected_yyyymmdd len = %d, want 2", len(step.Request.ExpectedYYYYMMDD))
				}
				if step.Expect.GapStatus != "clean" {
					t.Errorf("gap_status = %q, want clean", step.Expect.GapStatus)
				}
				if step.Expect.GapCount == nil || *step.Expect.GapCount != 0 {
					t.Errorf("gap_count = %v, want 0", step.Expect.GapCount)
				}
				if step.Expect.DuplicateCount == nil || *step.Expect.DuplicateCount != 0 {
					t.Errorf("duplicate_count = %v, want 0", step.Expect.DuplicateCount)
				}
				if step.Expect.ProviderDelayDays == nil || *step.Expect.ProviderDelayDays != 0 {
					t.Errorf("provider_delay_days = %v, want 0", step.Expect.ProviderDelayDays)
				}
			}
		})
	}
}

func TestValidateStartBacktestUniverseReference(t *testing.T) {
	yamlText := `
name: valid_start_backtest_universe
universes:
  - name: kr-backtest-smoke
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      universe: kr-backtest-smoke
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
    expect:
      status: ok
`
	sc, err := ParseScenario([]byte(yamlText))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(sc.Steps) != 1 {
		t.Fatalf("steps = %d, want 1", len(sc.Steps))
	}
	step := sc.Steps[0]
	if step.Request.Market != "kr" {
		t.Errorf("market = %q, want kr", step.Request.Market)
	}
	if len(step.Request.Symbols) != 1 || step.Request.Symbols[0] != "005930" {
		t.Errorf("symbols = %v, want [005930]", step.Request.Symbols)
	}
	if step.Request.Universe != "" {
		t.Errorf("universe = %q, want empty", step.Request.Universe)
	}
}

func TestValidateStartBacktestRejectsMissingMarket(t *testing.T) {
	for _, tc := range []struct {
		name       string
		marketLine string
	}{
		{name: "missing", marketLine: ""},
		{name: "unspecified", marketLine: "      market: unspecified\n"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			yamlText := `
name: missing_market
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
` + tc.marketLine + `      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
      symbols: ["005930"]
`
			_, err := ParseScenario([]byte(yamlText))
			if err == nil {
				t.Fatal("expected error, got nil")
			}
			if !strings.Contains(err.Error(), "start_backtest requires request.market") {
				t.Errorf("error = %v, want it to contain missing market error", err)
			}
		})
	}
}

func TestValidateStartBacktestUniverseRejectsMissingMarket(t *testing.T) {
	yamlText := `
name: missing_universe_market
universes:
  - name: kr-backtest-smoke
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      universe: kr-backtest-smoke
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
`
	_, err := ParseScenario([]byte(yamlText))
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if !strings.Contains(err.Error(), "start_backtest requires request.market") {
		t.Errorf("error = %v, want it to contain missing market error", err)
	}
}

func TestValidateStartBacktestRejectsMissingSelector(t *testing.T) {
	yamlText := `
name: missing_selector
steps:
  - id: step1
    action: start_backtest
    request:
      strategy_id: strat1
      market: kr
      timeframe: daily
      from_unix_ms: 100
      to_unix_ms: 200
`
	_, err := ParseScenario([]byte(yamlText))
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if !strings.Contains(err.Error(), "start_backtest requires request.universe, request.symbols, or request.instrument_ids") {
		t.Errorf("error = %v, want it to contain missing selector error", err)
	}
}

func TestValidateBacktestScenarioMatrix(t *testing.T) {
	yamlText := `
name: valid_matrix_scenario
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat1", "strat2"]
  timeframes: ["daily", "minute_1"]
  periods:
    - id: p1
      from_unix_ms: 1000
      to_unix_ms: 2000
    - id: p2
      from_unix_ms: 3000
      to_unix_ms: 4000
`
	sc, err := ParseScenario([]byte(yamlText))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if sc.Name != "valid_matrix_scenario" {
		t.Errorf("name = %q, want valid_matrix_scenario", sc.Name)
	}
	if sc.Matrix == nil {
		t.Fatalf("matrix is nil")
	}
	if len(sc.Matrix.Universes) != 1 || sc.Matrix.Universes[0] != "univ1" {
		t.Errorf("matrix universes = %v, want [univ1]", sc.Matrix.Universes)
	}
}

func TestValidateRejectsInvalidBacktestScenarioMatrix(t *testing.T) {
	cases := []struct {
		name        string
		yaml        string
		expectedErr string
	}{
		{
			name: "no steps and no matrix",
			yaml: `
name: no_steps_no_matrix
`,
			expectedErr: "has no steps and no matrix",
		},
		{
			name: "empty universe list in matrix",
			yaml: `
name: empty_univ
matrix:
  universes: []
  strategies: ["strat1"]
  timeframes: ["daily"]
  periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
`,
			expectedErr: "matrix universes is empty",
		},
		{
			name: "unknown universe",
			yaml: `
name: unknown_univ
matrix:
  universes: ["univ_unknown"]
  strategies: ["strat1"]
  timeframes: ["daily"]
  periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
`,
			expectedErr: "referenced universe \"univ_unknown\" not found",
		},
		{
			name: "invalid timeframe",
			yaml: `
name: invalid_tf
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat1"]
  timeframes: ["invalid_timeframe"]
  periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
`,
			expectedErr: "unsupported timeframe",
		},
		{
			name: "reversed period",
			yaml: `
name: reversed_period
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat1"]
  timeframes: ["daily"]
  periods: [{id: "p1", from_unix_ms: 200, to_unix_ms: 100}]
`,
			expectedErr: "cannot be after to_unix_ms",
		},
		{
			name: "whitespace in generated ID",
			yaml: `
name: whitespace_id
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat 1"]
  timeframes: ["daily"]
  periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
`,
			expectedErr: "contains whitespace",
		},
		{
			name: "duplicate period ID",
			yaml: `
name: duplicate_period_id
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat1"]
  timeframes: ["daily"]
  periods:
    - id: p1
      from_unix_ms: 100
      to_unix_ms: 200
    - id: p1
      from_unix_ms: 300
      to_unix_ms: 400
`,
			expectedErr: "matrix generated duplicate ID",
		},
		{
			name: "universe with empty market",
			yaml: `
name: empty_market
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat1"]
  timeframes: ["daily"]
  periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
`,
			expectedErr: "requires a concrete market for backtest runs",
		},
		{
			name: "universe with unspecified market",
			yaml: `
name: unspecified_market
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: unspecified
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat1"]
  timeframes: ["daily"]
  periods: [{id: "p1", from_unix_ms: 100, to_unix_ms: 200}]
`,
			expectedErr: "requires a concrete market for backtest runs",
		},
		{
			name: "missing from_unix_ms",
			yaml: `
name: missing_from_unix_ms
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat1"]
  timeframes: ["daily"]
  periods: [{id: "p1", to_unix_ms: 200}]
`,
			expectedErr: "from_unix_ms and to_unix_ms are required",
		},
		{
			name: "missing to_unix_ms",
			yaml: `
name: missing_to_unix_ms
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
matrix:
  universes: ["univ1"]
  strategies: ["strat1"]
  timeframes: ["daily"]
  periods: [{id: "p1", from_unix_ms: 100}]
`,
			expectedErr: "from_unix_ms and to_unix_ms are required",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			_, err := ParseScenario([]byte(tc.yaml))
			if err == nil {
				t.Fatalf("expected error containing %q, got nil", tc.expectedErr)
			}
			if !strings.Contains(err.Error(), tc.expectedErr) {
				t.Errorf("error = %v, want it to contain %q", err, tc.expectedErr)
			}
		})
	}
}

func TestBacktestScenarioMatrixRunsExpandsInDeterministicOrder(t *testing.T) {
	yamlText := `
name: expansion_order
universes:
  - name: univ1
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["005930"]
  - name: univ2
    provider: kis
    selector_kind: watchlist
    market: kr
    symbols: ["000660"]
matrix:
  universes: ["univ1", "univ2"]
  strategies: ["strat1", "strat2"]
  timeframes: ["daily", "minute_1"]
  periods:
    - id: p1
      from_unix_ms: 1000
      to_unix_ms: 2000
    - id: p2
      from_unix_ms: 3000
      to_unix_ms: 4000
`
	sc, err := ParseScenario([]byte(yamlText))
	if err != nil {
		t.Fatalf("unexpected error parsing matrix scenario: %v", err)
	}

	runs, err := sc.MatrixRuns()
	if err != nil {
		t.Fatalf("unexpected error expanding matrix runs: %v", err)
	}

	wantIDs := []string{
		"univ1__strat1__daily__p1",
		"univ1__strat1__daily__p2",
		"univ1__strat1__minute_1__p1",
		"univ1__strat1__minute_1__p2",
		"univ1__strat2__daily__p1",
		"univ1__strat2__daily__p2",
		"univ1__strat2__minute_1__p1",
		"univ1__strat2__minute_1__p2",
		"univ2__strat1__daily__p1",
		"univ2__strat1__daily__p2",
		"univ2__strat1__minute_1__p1",
		"univ2__strat1__minute_1__p2",
		"univ2__strat2__daily__p1",
		"univ2__strat2__daily__p2",
		"univ2__strat2__minute_1__p1",
		"univ2__strat2__minute_1__p2",
	}

	if len(runs) != len(wantIDs) {
		t.Fatalf("runs = %d, want %d", len(runs), len(wantIDs))
	}

	for i, run := range runs {
		if run.ID != wantIDs[i] {
			t.Errorf("runs[%d].ID = %q, want %q", i, run.ID, wantIDs[i])
		}
		parts := strings.Split(run.ID, "__")
		if run.Universe != parts[0] {
			t.Errorf("runs[%d].Universe = %q, want %q", i, run.Universe, parts[0])
		}
		if run.StrategyID != parts[1] {
			t.Errorf("runs[%d].StrategyID = %q, want %q", i, run.StrategyID, parts[1])
		}
		if run.Timeframe != parts[2] {
			t.Errorf("runs[%d].Timeframe = %q, want %q", i, run.Timeframe, parts[2])
		}
		if parts[3] == "p1" {
			if run.FromUnixMs != 1000 || run.ToUnixMs != 2000 {
				t.Errorf("runs[%d] period p1 from/to = %d/%d, want 1000/2000", i, run.FromUnixMs, run.ToUnixMs)
			}
		} else if parts[3] == "p2" {
			if run.FromUnixMs != 3000 || run.ToUnixMs != 4000 {
				t.Errorf("runs[%d] period p2 from/to = %d/%d, want 3000/4000", i, run.FromUnixMs, run.ToUnixMs)
			}
		}
	}
}
