package kis

import (
	"encoding/json"
	"os"
	"path/filepath"
	"testing"
	"time"

	"git.toki-labs.com/toki/alt/packages/domain/market"
)

const fixtureDir = "../../../testdata/providers/kis"

func readFixture(t *testing.T, name string) []byte {
	t.Helper()
	data, err := os.ReadFile(filepath.Join(fixtureDir, name))
	if err != nil {
		t.Fatalf("read fixture %s: %v", name, err)
	}
	return data
}

func TestLoadDailyItemChartPriceFixture(t *testing.T) {
	req, err := DecodeDailyItemChartPriceRequest(readFixture(t, "daily_itemchartprice_request.sample.json"))
	if err != nil {
		t.Fatalf("decode request: %v", err)
	}
	if req.Endpoint != "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" {
		t.Errorf("endpoint mismatch: got %q", req.Endpoint)
	}
	if req.TrID != "FHKST03010100" {
		t.Errorf("tr_id mismatch: got %q", req.TrID)
	}

	resp, err := DecodeDailyItemChartPriceResponse(readFixture(t, "daily_itemchartprice_response.sample.json"))
	if err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if resp.Output1.ShortCode != "005930" {
		t.Errorf("output1 short code mismatch: got %q", resp.Output1.ShortCode)
	}
	if len(resp.Output2) != 2 {
		t.Fatalf("output2 row count: got %d, want 2", len(resp.Output2))
	}
}

// expectedDailyBarsFixture is a test-only view of
// daily_bars_normalized.expected.json. It is the contract that
// NormalizeDailyBars must reproduce; keeping it here (not in the production
// package) avoids widening the provider API for test concerns.
type expectedDailyBarsFixture struct {
	Instrument struct {
		ID              string            `json:"id"`
		Market          string            `json:"market"`
		Venue           string            `json:"venue"`
		Symbol          string            `json:"symbol"`
		Name            string            `json:"name"`
		Currency        string            `json:"currency"`
		AssetType       string            `json:"asset_type"`
		ProviderSymbols map[string]string `json:"provider_symbols"`
	} `json:"instrument"`
	Bars []struct {
		InstrumentID string `json:"instrument_id"`
		Timeframe    string `json:"timeframe"`
		Timestamp    string `json:"timestamp"`
		Open         string `json:"open"`
		High         string `json:"high"`
		Low          string `json:"low"`
		Close        string `json:"close"`
		Volume       string `json:"volume"`
		Currency     string `json:"currency"`
	} `json:"bars"`
}

func decodeExpectedDailyBarsFixture(t *testing.T, name string) expectedDailyBarsFixture {
	t.Helper()
	var fx expectedDailyBarsFixture
	if err := json.Unmarshal(readFixture(t, name), &fx); err != nil {
		t.Fatalf("decode expected bars fixture %s: %v", name, err)
	}
	return fx
}

func (fx expectedDailyBarsFixture) instrument() market.Instrument {
	return market.Instrument{
		ID:              market.InstrumentID(fx.Instrument.ID),
		Market:          market.Market(fx.Instrument.Market),
		Venue:           market.Venue(fx.Instrument.Venue),
		Symbol:          fx.Instrument.Symbol,
		Name:            fx.Instrument.Name,
		Currency:        market.Currency(fx.Instrument.Currency),
		AssetType:       market.AssetType(fx.Instrument.AssetType),
		ProviderSymbols: fx.Instrument.ProviderSymbols,
	}
}

func (fx expectedDailyBarsFixture) bars(t *testing.T) []market.Bar {
	t.Helper()
	bars := make([]market.Bar, 0, len(fx.Bars))
	for i, b := range fx.Bars {
		ts, err := time.Parse(time.RFC3339, b.Timestamp)
		if err != nil {
			t.Fatalf("parse expected timestamp[%d] %q: %v", i, b.Timestamp, err)
		}
		cur := market.Currency(b.Currency)
		bars = append(bars, market.Bar{
			InstrumentID: market.InstrumentID(b.InstrumentID),
			Timeframe:    market.Timeframe(b.Timeframe),
			Timestamp:    ts,
			Open:         market.Price{Currency: cur, Amount: market.Decimal{Value: b.Open}},
			High:         market.Price{Currency: cur, Amount: market.Decimal{Value: b.High}},
			Low:          market.Price{Currency: cur, Amount: market.Decimal{Value: b.Low}},
			Close:        market.Price{Currency: cur, Amount: market.Decimal{Value: b.Close}},
			Volume:       market.Quantity{Amount: market.Decimal{Value: b.Volume}},
		})
	}
	return bars
}

func TestNormalizeDailyItemChartPriceBars(t *testing.T) {
	resp, err := DecodeDailyItemChartPriceResponse(readFixture(t, "daily_itemchartprice_response.sample.json"))
	if err != nil {
		t.Fatalf("decode response: %v", err)
	}

	expected := decodeExpectedDailyBarsFixture(t, "daily_bars_normalized.expected.json")
	inst := expected.instrument()

	// Lock the KRX daily defaults the fixture encodes before normalizing.
	if inst.ID != market.InstrumentID("KRX:005930") {
		t.Errorf("fixture instrument id: got %q, want %q", inst.ID, "KRX:005930")
	}
	if inst.Currency != market.CurrencyKRW {
		t.Errorf("fixture instrument currency: got %q, want %q", inst.Currency, market.CurrencyKRW)
	}
	if inst.Venue != market.VenueKRX {
		t.Errorf("fixture instrument venue: got %q, want %q", inst.Venue, market.VenueKRX)
	}
	if got := inst.ProviderSymbols[string(market.ProviderKIS)]; got != "005930" {
		t.Errorf("fixture kis provider symbol: got %q, want %q", got, "005930")
	}

	bars, err := NormalizeDailyBars(resp, inst)
	if err != nil {
		t.Fatalf("normalize bars: %v", err)
	}

	want := expected.bars(t)
	if len(bars) != len(want) {
		t.Fatalf("bar count: got %d, want %d", len(bars), len(want))
	}
	for i := range want {
		assertBarEqual(t, i, bars[i], want[i])
	}
}

func TestLoadOverseasDailyPriceFixture(t *testing.T) {
	req, err := DecodeOverseasDailyPriceRequest(readFixture(t, "overseas_dailyprice_request.sample.json"))
	if err != nil {
		t.Fatalf("decode overseas request: %v", err)
	}
	if req.Endpoint != "/uapi/overseas-price/v1/quotations/dailyprice" {
		t.Errorf("overseas endpoint mismatch: got %q", req.Endpoint)
	}
	if req.TrID != "HHDFS76240000" {
		t.Errorf("overseas tr_id mismatch: got %q", req.TrID)
	}
	if req.Params["EXCD"] != "NAS" || req.Params["SYMB"] != "AAPL" {
		t.Errorf("overseas params mismatch: EXCD=%q SYMB=%q", req.Params["EXCD"], req.Params["SYMB"])
	}

	resp, err := DecodeOverseasDailyPriceResponse(readFixture(t, "overseas_dailyprice_response.sample.json"))
	if err != nil {
		t.Fatalf("decode overseas response: %v", err)
	}
	if len(resp.Output2) != 2 {
		t.Fatalf("overseas output2 row count: got %d, want 2", len(resp.Output2))
	}
	// Lock the overseas field renames (xymd/clos) the decoder must read.
	if resp.Output2[0].BusinessDate != "20240528" {
		t.Errorf("overseas xymd mismatch: got %q", resp.Output2[0].BusinessDate)
	}
	if resp.Output2[0].Close != "189.99" {
		t.Errorf("overseas clos mismatch: got %q", resp.Output2[0].Close)
	}
}

func TestNormalizeOverseasDailyPriceBars(t *testing.T) {
	resp, err := DecodeOverseasDailyPriceResponse(readFixture(t, "overseas_dailyprice_response.sample.json"))
	if err != nil {
		t.Fatalf("decode overseas response: %v", err)
	}

	expected := decodeExpectedDailyBarsFixture(t, "overseas_daily_bars_normalized.expected.json")
	inst := expected.instrument()

	// Lock the US/NASDAQ defaults the fixture encodes before normalizing.
	if inst.ID != market.InstrumentID("NASDAQ:AAPL") {
		t.Errorf("fixture instrument id: got %q, want %q", inst.ID, "NASDAQ:AAPL")
	}
	if inst.Currency != market.CurrencyUSD {
		t.Errorf("fixture instrument currency: got %q, want %q", inst.Currency, market.CurrencyUSD)
	}
	if inst.Venue != market.VenueNASDAQ {
		t.Errorf("fixture instrument venue: got %q, want %q", inst.Venue, market.VenueNASDAQ)
	}
	if inst.AssetType != market.AssetTypeEquity {
		t.Errorf("fixture instrument asset type: got %q, want %q", inst.AssetType, market.AssetTypeEquity)
	}

	bars, err := NormalizeOverseasDailyBars(resp, inst)
	if err != nil {
		t.Fatalf("normalize overseas bars: %v", err)
	}

	want := expected.bars(t)
	if len(bars) != len(want) {
		t.Fatalf("bar count: got %d, want %d", len(bars), len(want))
	}
	for i := range want {
		assertBarEqual(t, i, bars[i], want[i])
	}

	// Bars must be USD at the venue's America/New_York midnight (EDT -04:00 in
	// May), not the KRX/KRW domestic default.
	if bars[0].Close.Currency != market.CurrencyUSD {
		t.Errorf("bar currency: got %q, want USD", bars[0].Close.Currency)
	}
	if got := bars[0].Timestamp.Format(time.RFC3339); got != "2024-05-28T00:00:00-04:00" {
		t.Errorf("bar timestamp offset: got %q, want %q", got, "2024-05-28T00:00:00-04:00")
	}
}

func assertBarEqual(t *testing.T, idx int, got, want market.Bar) {
	t.Helper()
	if got.InstrumentID != want.InstrumentID {
		t.Errorf("bar[%d] instrument id: got %q, want %q", idx, got.InstrumentID, want.InstrumentID)
	}
	if got.Timeframe != want.Timeframe {
		t.Errorf("bar[%d] timeframe: got %q, want %q", idx, got.Timeframe, want.Timeframe)
	}
	if !got.Timestamp.Equal(want.Timestamp) {
		t.Errorf("bar[%d] timestamp: got %s, want %s", idx, got.Timestamp.Format(time.RFC3339), want.Timestamp.Format(time.RFC3339))
	}
	// Lock the +09:00 wall-clock representation, not just the instant.
	if got.Timestamp.Format(time.RFC3339) != want.Timestamp.Format(time.RFC3339) {
		t.Errorf("bar[%d] timestamp offset: got %s, want %s", idx, got.Timestamp.Format(time.RFC3339), want.Timestamp.Format(time.RFC3339))
	}
	assertPrice(t, idx, "open", got.Open, want.Open)
	assertPrice(t, idx, "high", got.High, want.High)
	assertPrice(t, idx, "low", got.Low, want.Low)
	assertPrice(t, idx, "close", got.Close, want.Close)
	if got.Volume.Amount.Value != want.Volume.Amount.Value {
		t.Errorf("bar[%d] volume: got %q, want %q", idx, got.Volume.Amount.Value, want.Volume.Amount.Value)
	}
}

func assertPrice(t *testing.T, idx int, field string, got, want market.Price) {
	t.Helper()
	if got.Currency != want.Currency {
		t.Errorf("bar[%d] %s currency: got %q, want %q", idx, field, got.Currency, want.Currency)
	}
	if got.Amount.Value != want.Amount.Value {
		t.Errorf("bar[%d] %s amount: got %q, want %q", idx, field, got.Amount.Value, want.Amount.Value)
	}
}

// providerSymbolsFixture is a test-only view of provider_symbols.sample.json. It
// asserts that the selector vocabulary added to packages/domain/market stays in
// sync with the fixture that downstream universe/import work will consume.
type providerSymbolsFixture struct {
	Provider  string `json:"provider"`
	Selectors []struct {
		Kind    string   `json:"kind"`
		Venue   string   `json:"venue"`
		Name    string   `json:"name"`
		Symbols []string `json:"symbols"`
	} `json:"selectors"`
	Instruments []struct {
		ID              string            `json:"id"`
		Market          string            `json:"market"`
		Venue           string            `json:"venue"`
		Symbol          string            `json:"symbol"`
		Name            string            `json:"name"`
		Currency        string            `json:"currency"`
		AssetType       string            `json:"asset_type"`
		ProviderSymbols map[string]string `json:"provider_symbols"`
	} `json:"instruments"`
}

func TestProviderSymbolsFixtureVocabulary(t *testing.T) {
	var fx providerSymbolsFixture
	if err := json.Unmarshal(readFixture(t, "provider_symbols.sample.json"), &fx); err != nil {
		t.Fatalf("decode provider symbols fixture: %v", err)
	}

	if market.Provider(fx.Provider) != market.ProviderKIS {
		t.Errorf("provider: got %q, want %q", fx.Provider, market.ProviderKIS)
	}

	wantKinds := map[market.UniverseSelectorKind]bool{
		market.UniverseSelectorWatchlist: false,
		market.UniverseSelectorExchange:  false,
		market.UniverseSelectorSector:    false,
	}
	usWatchlistSymbols := map[string]bool{}
	for i, sel := range fx.Selectors {
		kind := market.UniverseSelectorKind(sel.Kind)
		if _, ok := wantKinds[kind]; !ok {
			t.Errorf("selector[%d] unexpected kind %q", i, sel.Kind)
			continue
		}
		wantKinds[kind] = true
		if len(sel.Symbols) == 0 {
			t.Errorf("selector[%d] %q has no symbols", i, sel.Kind)
		}
		switch kind {
		case market.UniverseSelectorWatchlist:
			for _, s := range sel.Symbols {
				usWatchlistSymbols[s] = true
			}
		case market.UniverseSelectorExchange:
			if market.Venue(sel.Venue) != market.VenueKRX {
				t.Errorf("exchange selector venue: got %q, want %q", sel.Venue, market.VenueKRX)
			}
		case market.UniverseSelectorSector:
			if sel.Name == "" {
				t.Errorf("sector selector missing name")
			}
		}
	}
	for kind, seen := range wantKinds {
		if !seen {
			t.Errorf("selector kind %q missing from fixture", kind)
		}
	}

	// Verify watchlist contains required US symbols.
	for _, wanted := range []string{"AAPL", "SPY"} {
		if !usWatchlistSymbols[wanted] {
			t.Errorf("watchlist selector missing US symbol %q", wanted)
		}
	}

	if len(fx.Instruments) == 0 {
		t.Fatal("provider symbols fixture has no instruments")
	}

	// Required US instruments: key is "venue:symbol", values are the exact fields.
	requiredUS := map[string]struct {
		market    market.Market
		venue     market.Venue
		symbol    string
		currency  market.Currency
		assetType market.AssetType
	}{
		"NASDAQ:AAPL": {market.MarketUS, market.VenueNASDAQ, "AAPL", market.CurrencyUSD, market.AssetTypeEquity},
		"NASDAQ:SPY":  {market.MarketUS, market.VenueNASDAQ, "SPY", market.CurrencyUSD, market.AssetTypeETF},
	}

	// Build instrument map for quick lookup by id.
	instMap := make(map[string]struct {
		ID              string            `json:"id"`
		Market          string            `json:"market"`
		Venue           string            `json:"venue"`
		Symbol          string            `json:"symbol"`
		Name            string            `json:"name"`
		Currency        string            `json:"currency"`
		AssetType       string            `json:"asset_type"`
		ProviderSymbols map[string]string `json:"provider_symbols"`
	}, len(fx.Instruments))
	for _, inst := range fx.Instruments {
		instMap[inst.ID] = inst
	}

	// Validate US required instruments.
	for key, want := range requiredUS {
		inst, ok := instMap[key]
		if !ok {
			t.Errorf("required US instrument %q missing from fixture", key)
			continue
		}
		if market.Market(inst.Market) != want.market {
			t.Errorf("instrument[%s] market: got %q, want %q", key, inst.Market, want.market)
		}
		if market.Venue(inst.Venue) != want.venue {
			t.Errorf("instrument[%s] venue: got %q, want %q", key, inst.Venue, want.venue)
		}
		if inst.Symbol != want.symbol {
			t.Errorf("instrument[%s] symbol: got %q, want %q", key, inst.Symbol, want.symbol)
		}
		if market.Currency(inst.Currency) != want.currency {
			t.Errorf("instrument[%s] currency: got %q, want %q", key, inst.Currency, want.currency)
		}
		if market.AssetType(inst.AssetType) != want.assetType {
			t.Errorf("instrument[%s] asset_type: got %q, want %q", key, inst.AssetType, want.assetType)
		}
		sym, ok := inst.ProviderSymbols[string(market.ProviderKIS)]
		if !ok || sym == "" {
			t.Errorf("instrument[%s] missing kis provider symbol", key)
			continue
		}
		if sym != inst.Symbol {
			t.Errorf("instrument[%s] kis provider symbol %q != symbol %q", key, sym, inst.Symbol)
		}
	}

	// Validate non-US instruments still allow expected enum values.
	for i, inst := range fx.Instruments {
		v := market.Venue(inst.Venue)
		if v != market.VenueKRX && v != market.VenueNASDAQ && v != market.VenueNYSE {
			t.Errorf("instrument[%d] unexpected venue: %q", i, inst.Venue)
		}
		c := market.Currency(inst.Currency)
		if c != market.CurrencyKRW && c != market.CurrencyUSD {
			t.Errorf("instrument[%d] unexpected currency: %q", i, inst.Currency)
		}
		at := market.AssetType(inst.AssetType)
		if at != market.AssetTypeEquity && at != market.AssetTypeETF {
			t.Errorf("instrument[%d] unexpected asset type: %q", i, inst.AssetType)
		}
	}
}
