package operator

import (
	"bytes"
	"context"
	"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"}}},
		barsResp:            &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "KRX:005930"}}},
	})
	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",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}
}

func TestRunScenarioMarketDataStatusUS(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: "NASDAQ:AAPL"}}},
		barsResp:            &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "NASDAQ:AAPL"}}},
	})
	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",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output %q missing %q", out, want)
		}
	}
}

// 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"}}},
		barsResp:            &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "KRX:005930"}}},
	}
	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)
	}

	// Call order: import precedes both status reads.
	wantOrder := []string{"import_daily_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)
}
