package kis

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	"git.toki-labs.com/toki/alt/packages/domain/market"
	"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
)

const (
	RealBaseURL  = "https://openapi.koreainvestment.com:9443"
	PaperBaseURL = "https://openapivts.koreainvestment.com:29443"

	tokenPath = "/oauth2/tokenP"

	DefaultUserAgent = "ALT worker KIS smoke"
)

type Environment string

const (
	EnvironmentReal  Environment = "real"
	EnvironmentPaper Environment = "paper"
)

type ErrorKind string

const (
	ErrorUnavailable ErrorKind = "unavailable"
	ErrorAuth        ErrorKind = "auth"
	ErrorQuota       ErrorKind = "quota"
	ErrorMalformed   ErrorKind = "malformed"
	ErrorProvider    ErrorKind = "provider"
)

type Error struct {
	Kind       ErrorKind
	Op         string
	StatusCode int
	Code       string
	Message    string
	Err        error
}

func (e *Error) Error() string {
	var b strings.Builder
	if e.Op != "" {
		b.WriteString("kis ")
		b.WriteString(e.Op)
		b.WriteString(": ")
	}
	b.WriteString(string(e.Kind))
	if e.StatusCode != 0 {
		fmt.Fprintf(&b, " status=%d", e.StatusCode)
	}
	if e.Code != "" {
		fmt.Fprintf(&b, " code=%s", e.Code)
	}
	if e.Message != "" {
		fmt.Fprintf(&b, " msg=%s", e.Message)
	}
	if e.Err != nil {
		fmt.Fprintf(&b, ": %v", e.Err)
	}
	return b.String()
}

func (e *Error) Unwrap() error {
	return e.Err
}

func IsErrorKind(err error, kind ErrorKind) bool {
	var kisErr *Error
	return errors.As(err, &kisErr) && kisErr.Kind == kind
}

type Config struct {
	Environment        Environment
	ConfigError        string
	BaseURL            string
	AppKey             string
	AppSecret          string
	AccountNo          string
	AccountProductCode string
	UserAgent          string
	HTTPClient         *http.Client
}

func ConfigFromEnv() Config {
	env := Environment(strings.TrimSpace(os.Getenv("KIS_ACTIVE_ENV")))
	switch env {
	case EnvironmentReal:
		return Config{
			Environment:        EnvironmentReal,
			BaseURL:            RealBaseURL,
			AppKey:             os.Getenv("KIS_REAL_APP_KEY"),
			AppSecret:          os.Getenv("KIS_REAL_APP_SECRET"),
			AccountNo:          os.Getenv("KIS_REAL_CANO"),
			AccountProductCode: os.Getenv("KIS_REAL_ACNT_PRDT_CD"),
		}
	case EnvironmentPaper:
		return Config{
			Environment:        EnvironmentPaper,
			BaseURL:            PaperBaseURL,
			AppKey:             os.Getenv("KIS_PAPER_APP_KEY"),
			AppSecret:          os.Getenv("KIS_PAPER_APP_SECRET"),
			AccountNo:          os.Getenv("KIS_PAPER_CANO"),
			AccountProductCode: os.Getenv("KIS_PAPER_ACNT_PRDT_CD"),
		}
	default:
		return Config{
			Environment: env,
			ConfigError: `KIS_ACTIVE_ENV must be "paper" or "real"`,
		}
	}
}

type Client struct {
	cfg        Config
	httpClient *http.Client
}

func NewClient(cfg Config) *Client {
	if cfg.UserAgent == "" {
		cfg.UserAgent = DefaultUserAgent
	}
	cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
	httpClient := cfg.HTTPClient
	if httpClient == nil {
		httpClient = &http.Client{Timeout: 20 * time.Second}
	}
	return &Client{cfg: cfg, httpClient: httpClient}
}

type Token struct {
	AccessToken string
	ExpiresAt   time.Time
}

func (c *Client) Auth(ctx context.Context) (Token, error) {
	if err := c.validateBase("auth"); err != nil {
		return Token{}, err
	}

	payload := map[string]string{
		"grant_type": "client_credentials",
		"appkey":     c.cfg.AppKey,
		"appsecret":  c.cfg.AppSecret,
	}
	body, status, err := c.doJSON(ctx, http.MethodPost, tokenPath, nil, payload, nil)
	if err != nil {
		return Token{}, err
	}
	if status != http.StatusOK {
		return Token{}, c.httpStatusError("auth", status, body)
	}

	var resp struct {
		AccessToken string `json:"access_token"`
		ExpiresAt   string `json:"access_token_token_expired"`
	}
	if err := json.Unmarshal(body, &resp); err != nil {
		return Token{}, &Error{Kind: ErrorMalformed, Op: "auth", Err: err}
	}
	if resp.AccessToken == "" {
		return Token{}, &Error{Kind: ErrorAuth, Op: "auth", Message: "missing access_token"}
	}
	expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", resp.ExpiresAt, time.Local)
	if err != nil {
		return Token{}, &Error{Kind: ErrorMalformed, Op: "auth", Message: "invalid token expiry", Err: err}
	}
	return Token{AccessToken: resp.AccessToken, ExpiresAt: expiresAt}, nil
}

type DailyItemChartPriceQuery struct {
	MarketDivCode string
	Symbol        string
	From          string
	To            string
	PeriodDivCode string
	AdjustedPrice string
}

func (q DailyItemChartPriceQuery) withDefaults() DailyItemChartPriceQuery {
	if q.MarketDivCode == "" {
		q.MarketDivCode = "J"
	}
	if q.PeriodDivCode == "" {
		q.PeriodDivCode = "D"
	}
	if q.AdjustedPrice == "" {
		q.AdjustedPrice = "0"
	}
	return q
}

func (q DailyItemChartPriceQuery) validate() error {
	if q.Symbol == "" {
		return &Error{Kind: ErrorMalformed, Op: "daily", Message: "symbol is required"}
	}
	if !isYYYYMMDD(q.From) {
		return &Error{Kind: ErrorMalformed, Op: "daily", Message: "from must be YYYYMMDD"}
	}
	if !isYYYYMMDD(q.To) {
		return &Error{Kind: ErrorMalformed, Op: "daily", Message: "to must be YYYYMMDD"}
	}
	if q.From > q.To {
		return &Error{Kind: ErrorMalformed, Op: "daily", Message: "from must be before or equal to to"}
	}
	return nil
}

func (c *Client) FetchDailyItemChartPrice(ctx context.Context, query DailyItemChartPriceQuery) (DailyItemChartPriceResponse, error) {
	token, err := c.Auth(ctx)
	if err != nil {
		return DailyItemChartPriceResponse{}, err
	}
	return c.InquireDailyItemChartPrice(ctx, token.AccessToken, query)
}

func (c *Client) InquireDailyItemChartPrice(ctx context.Context, accessToken string, query DailyItemChartPriceQuery) (DailyItemChartPriceResponse, error) {
	if err := c.validateBase("daily"); err != nil {
		return DailyItemChartPriceResponse{}, err
	}
	if accessToken == "" {
		return DailyItemChartPriceResponse{}, &Error{Kind: ErrorAuth, Op: "daily", Message: "missing access token"}
	}
	query = query.withDefaults()
	if err := query.validate(); err != nil {
		return DailyItemChartPriceResponse{}, err
	}

	headers := map[string]string{
		"authorization": "Bearer " + accessToken,
		"appkey":        c.cfg.AppKey,
		"appsecret":     c.cfg.AppSecret,
		"tr_id":         DailyItemChartPriceTRID,
		"custtype":      "P",
		"tr_cont":       "",
	}
	params := url.Values{
		"FID_COND_MRKT_DIV_CODE": []string{query.MarketDivCode},
		"FID_INPUT_ISCD":         []string{query.Symbol},
		"FID_INPUT_DATE_1":       []string{query.From},
		"FID_INPUT_DATE_2":       []string{query.To},
		"FID_PERIOD_DIV_CODE":    []string{query.PeriodDivCode},
		"FID_ORG_ADJ_PRC":        []string{query.AdjustedPrice},
	}
	body, status, err := c.doJSON(ctx, http.MethodGet, DailyItemChartPricePath, headers, nil, params)
	if err != nil {
		return DailyItemChartPriceResponse{}, err
	}
	if status != http.StatusOK {
		return DailyItemChartPriceResponse{}, c.httpStatusError("daily", status, body)
	}

	var resp DailyItemChartPriceResponse
	if err := json.Unmarshal(body, &resp); err != nil {
		return DailyItemChartPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "daily", Err: err}
	}
	if resp.ReturnCode != "0" {
		return DailyItemChartPriceResponse{}, providerPayloadError("daily", resp.MessageCd, resp.Message)
	}
	if len(resp.Output2) == 0 {
		return DailyItemChartPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "daily", Message: "no output2 rows"}
	}
	return resp, nil
}

// OverseasDailyPriceQuery describes a KIS overseas 기간별시세 (dailyprice) request.
// ExchangeCode is the KIS EXCD value (e.g. NAS, NYS), BaseDate is the BYMD
// reference date the endpoint walks back from, PeriodCode is GUBN (0=day), and
// Adjusted is MODP.
type OverseasDailyPriceQuery struct {
	ExchangeCode string
	Symbol       string
	BaseDate     string
	PeriodCode   string
	Adjusted     string
}

func (q OverseasDailyPriceQuery) withDefaults() OverseasDailyPriceQuery {
	if q.PeriodCode == "" {
		q.PeriodCode = "0"
	}
	if q.Adjusted == "" {
		q.Adjusted = "1"
	}
	return q
}

func (q OverseasDailyPriceQuery) validate() error {
	if q.ExchangeCode == "" {
		return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "exchange code is required"}
	}
	if q.Symbol == "" {
		return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "symbol is required"}
	}
	if !isYYYYMMDD(q.BaseDate) {
		return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "base date must be YYYYMMDD"}
	}
	return nil
}

func (c *Client) FetchOverseasDailyPrice(ctx context.Context, query OverseasDailyPriceQuery) (OverseasDailyPriceResponse, error) {
	token, err := c.Auth(ctx)
	if err != nil {
		return OverseasDailyPriceResponse{}, err
	}
	return c.InquireOverseasDailyPrice(ctx, token.AccessToken, query)
}

func (c *Client) InquireOverseasDailyPrice(ctx context.Context, accessToken string, query OverseasDailyPriceQuery) (OverseasDailyPriceResponse, error) {
	if err := c.validateBase("overseas-daily"); err != nil {
		return OverseasDailyPriceResponse{}, err
	}
	if accessToken == "" {
		return OverseasDailyPriceResponse{}, &Error{Kind: ErrorAuth, Op: "overseas-daily", Message: "missing access token"}
	}
	query = query.withDefaults()
	if err := query.validate(); err != nil {
		return OverseasDailyPriceResponse{}, err
	}

	headers := map[string]string{
		"authorization": "Bearer " + accessToken,
		"appkey":        c.cfg.AppKey,
		"appsecret":     c.cfg.AppSecret,
		"tr_id":         OverseasDailyPriceTRID,
		"custtype":      "P",
		"tr_cont":       "",
	}
	params := url.Values{
		"AUTH": []string{""},
		"EXCD": []string{query.ExchangeCode},
		"SYMB": []string{query.Symbol},
		"GUBN": []string{query.PeriodCode},
		"BYMD": []string{query.BaseDate},
		"MODP": []string{query.Adjusted},
	}
	body, status, err := c.doJSON(ctx, http.MethodGet, OverseasDailyPricePath, headers, nil, params)
	if err != nil {
		return OverseasDailyPriceResponse{}, err
	}
	if status != http.StatusOK {
		return OverseasDailyPriceResponse{}, c.httpStatusError("overseas-daily", status, body)
	}

	var resp OverseasDailyPriceResponse
	if err := json.Unmarshal(body, &resp); err != nil {
		return OverseasDailyPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "overseas-daily", Err: err}
	}
	if resp.ReturnCode != "0" {
		return OverseasDailyPriceResponse{}, providerPayloadError("overseas-daily", resp.MessageCd, resp.Message)
	}
	if len(resp.Output2) == 0 {
		return OverseasDailyPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "no output2 rows"}
	}
	return resp, nil
}

type LiveProvider struct {
	client *Client
}

func NewLiveProvider(client *Client) *LiveProvider {
	return &LiveProvider{client: client}
}

func (p *LiveProvider) FetchDailyBars(ctx context.Context, request importer.DailyBarRequest) ([]importer.InstrumentBars, error) {
	if p == nil || p.client == nil {
		return nil, &Error{Kind: ErrorUnavailable, Op: "provider", Message: "KIS client is not configured"}
	}
	if request.Provider != "" && request.Provider != market.ProviderKIS {
		return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported provider %q", request.Provider)}
	}
	if len(request.Selector.Symbols) == 0 {
		return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "selector symbols are required"}
	}
	if request.From.IsZero() || request.To.IsZero() {
		return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "from/to date range is required"}
	}

	from := request.From.Format(kisDateLayout)
	to := request.To.Format(kisDateLayout)
	// Route by venue so a US request reaches the overseas endpoint instead of
	// silently falling back to the KR domestic endpoint/normalizer. The provider
	// rejects unsupported venues itself so a direct provider call cannot bypass
	// the capability gate and store mislabeled bars.
	switch request.Selector.Venue {
	case "", market.VenueKRX:
		return p.fetchDomesticDailyBars(ctx, request.Selector.Symbols, from, to)
	case market.VenueNASDAQ, market.VenueNYSE:
		return p.fetchOverseasDailyBars(ctx, request.Selector.Venue, request.Selector.Symbols, from, to)
	default:
		return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported venue %q", request.Selector.Venue)}
	}
}

// fetchDomesticDailyBars fetches KR/KRX daily bars through the domestic chart
// endpoint and the KST normalizer.
func (p *LiveProvider) fetchDomesticDailyBars(ctx context.Context, symbols []string, from, to string) ([]importer.InstrumentBars, error) {
	items := make([]importer.InstrumentBars, 0, len(symbols))
	for _, symbol := range symbols {
		resp, err := p.client.FetchDailyItemChartPrice(ctx, DailyItemChartPriceQuery{
			Symbol: symbol,
			From:   from,
			To:     to,
		})
		if err != nil {
			return nil, err
		}
		inst := instrumentFromDailyResponse(resp, symbol)
		bars, err := NormalizeDailyBars(resp, inst)
		if err != nil {
			return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "normalize daily bars", Err: err}
		}
		items = append(items, importer.InstrumentBars{Instrument: inst, Bars: bars})
	}
	return items, nil
}

// fetchOverseasDailyBars fetches US daily bars through the overseas dailyprice
// endpoint and the venue-timezone normalizer. The overseas dailyprice base date
// (BYMD) is the request's To date; the endpoint walks back from it. The overseas
// payload carries no name or asset type, so the instrument lands with its
// venue-derived market/currency and the KIS provider symbol only; richer
// instrument metadata is a later split's concern.
func (p *LiveProvider) fetchOverseasDailyBars(ctx context.Context, venue market.Venue, symbols []string, fromDate, toDate string) ([]importer.InstrumentBars, error) {
	exchangeCode, err := overseasExchangeCode(venue)
	if err != nil {
		return nil, err
	}
	items := make([]importer.InstrumentBars, 0, len(symbols))
	for _, symbol := range symbols {
		resp, err := p.client.FetchOverseasDailyPrice(ctx, OverseasDailyPriceQuery{
			ExchangeCode: exchangeCode,
			Symbol:       symbol,
			BaseDate:     toDate,
		})
		if err != nil {
			return nil, err
		}
		inst, err := overseasInstrument(venue, symbol)
		if err != nil {
			return nil, err
		}
		bars, err := NormalizeOverseasDailyBars(resp, inst)
		if err != nil {
			return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "normalize overseas daily bars", Err: err}
		}

		filteredBars := make([]market.Bar, 0, len(bars))
		for _, bar := range bars {
			barDate := bar.Timestamp.Format(kisDateLayout)
			if barDate >= fromDate && barDate <= toDate {
				filteredBars = append(filteredBars, bar)
			}
		}

		items = append(items, importer.InstrumentBars{Instrument: inst, Bars: filteredBars})
	}
	return items, nil
}

// overseasExchangeCode maps a US venue to the KIS overseas EXCD code.
func overseasExchangeCode(v market.Venue) (string, error) {
	switch v {
	case market.VenueNASDAQ:
		return "NAS", nil
	case market.VenueNYSE:
		return "NYS", nil
	default:
		return "", &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported overseas venue %q", v)}
	}
}

// overseasInstrument builds the US instrument identity for an overseas symbol.
// Market and currency come from the venue metadata so the instrument cannot
// inherit KR/KRW defaults.
func overseasInstrument(venue market.Venue, symbol string) (market.Instrument, error) {
	meta, ok := market.GetVenueMetadata(venue)
	if !ok {
		return market.Instrument{}, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unknown venue %q", venue)}
	}
	return market.Instrument{
		ID:       market.InstrumentID(string(venue) + ":" + symbol),
		Market:   meta.Market,
		Venue:    venue,
		Symbol:   symbol,
		Currency: meta.Currency,
		ProviderSymbols: map[string]string{
			string(market.ProviderKIS): symbol,
		},
	}, nil
}

func instrumentFromDailyResponse(resp DailyItemChartPriceResponse, requestedSymbol string) market.Instrument {
	symbol := resp.Output1.ShortCode
	if symbol == "" {
		symbol = requestedSymbol
	}
	return market.Instrument{
		ID:       market.InstrumentID("KRX:" + symbol),
		Market:   market.MarketKR,
		Venue:    market.VenueKRX,
		Symbol:   symbol,
		Name:     resp.Output1.Name,
		Currency: market.CurrencyKRW,
		ProviderSymbols: map[string]string{
			string(market.ProviderKIS): symbol,
		},
	}
}

func (c *Client) validateBase(op string) error {
	if c.cfg.ConfigError != "" {
		return &Error{Kind: ErrorUnavailable, Op: op, Message: c.cfg.ConfigError}
	}
	if c.cfg.BaseURL == "" {
		return &Error{Kind: ErrorUnavailable, Op: op, Message: "base URL is not configured"}
	}
	if c.cfg.AppKey == "" {
		return &Error{Kind: ErrorUnavailable, Op: op, Message: "app key is not configured"}
	}
	if c.cfg.AppSecret == "" {
		return &Error{Kind: ErrorUnavailable, Op: op, Message: "app secret is not configured"}
	}
	return nil
}

func (c *Client) doJSON(ctx context.Context, method, path string, headers map[string]string, payload map[string]string, params url.Values) ([]byte, int, error) {
	reqURL := c.cfg.BaseURL + path
	if len(params) > 0 {
		reqURL += "?" + params.Encode()
	}
	var body io.Reader
	if payload != nil {
		encoded, err := json.Marshal(payload)
		if err != nil {
			return nil, 0, &Error{Kind: ErrorMalformed, Op: strings.ToLower(method), Err: err}
		}
		body = bytes.NewReader(encoded)
	}
	req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
	if err != nil {
		return nil, 0, &Error{Kind: ErrorMalformed, Op: strings.ToLower(method), Err: err}
	}
	req.Header.Set("Content-Type", "application/json; charset=utf-8")
	req.Header.Set("Accept", "application/json")
	req.Header.Set("User-Agent", c.cfg.UserAgent)
	for key, value := range headers {
		req.Header.Set(key, value)
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, 0, &Error{Kind: ErrorUnavailable, Op: strings.ToLower(method), Err: err}
	}
	defer resp.Body.Close()

	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return nil, resp.StatusCode, &Error{Kind: ErrorUnavailable, Op: strings.ToLower(method), Err: err}
	}
	return data, resp.StatusCode, nil
}

func (c *Client) httpStatusError(op string, status int, body []byte) error {
	message, code := responseMessage(body)
	return &Error{
		Kind:       classifyFailure(status, code, message),
		Op:         op,
		StatusCode: status,
		Code:       code,
		Message:    message,
	}
}

func providerPayloadError(op, code, message string) error {
	return &Error{
		Kind:    classifyFailure(http.StatusOK, code, message),
		Op:      op,
		Code:    code,
		Message: message,
	}
}

func responseMessage(body []byte) (string, string) {
	var payload struct {
		Code    string `json:"msg_cd"`
		Message string `json:"msg1"`
	}
	if err := json.Unmarshal(body, &payload); err == nil && (payload.Code != "" || payload.Message != "") {
		return payload.Message, payload.Code
	}
	trimmed := strings.TrimSpace(string(body))
	if len(trimmed) > 300 {
		trimmed = trimmed[:300]
	}
	return trimmed, ""
}

func classifyFailure(status int, code, message string) ErrorKind {
	if status == http.StatusTooManyRequests {
		return ErrorQuota
	}
	if status == http.StatusUnauthorized || status == http.StatusForbidden {
		return ErrorAuth
	}
	text := strings.ToLower(code + " " + message)
	switch {
	case strings.Contains(text, "token"),
		strings.Contains(text, "auth"),
		strings.Contains(text, "oauth"),
		strings.Contains(text, "인증"):
		return ErrorAuth
	case strings.Contains(text, "quota"),
		strings.Contains(text, "rate"),
		strings.Contains(text, "exceed"),
		strings.Contains(text, "초당"),
		strings.Contains(text, "과다"):
		return ErrorQuota
	default:
		return ErrorProvider
	}
}

func isYYYYMMDD(value string) bool {
	if len(value) != 8 {
		return false
	}
	for _, r := range value {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}
