package cbr

import (
	"context"
	"encoding/xml"
	"math/big"
	"net"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/tech/sendico/fx/ingestor/internal/market/common"
	mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
	"github.com/tech/sendico/pkg/merrors"
	"github.com/tech/sendico/pkg/mlogger"
	"github.com/tech/sendico/pkg/model"
	"go.uber.org/zap"
	"golang.org/x/net/html/charset"
)

type cbrConnector struct {
	id            mmodel.Driver
	provider      string
	http          *httpClient
	base          string
	dailyPath     string
	directoryPath string
	dynamicPath   string
	logger        mlogger.Logger

	byISO map[string]valuteInfo
	byID  map[string]valuteInfo
}

const defaultCBRBaseURL = "https://www.cbr.ru"
const (
	defaultDirectoryPath = "/scripts/XML_valFull.asp"
	defaultDailyPath     = "/scripts/XML_daily.asp"
	defaultDynamicPath   = "/scripts/XML_dynamic.asp"
)

const (
	defaultDialTimeout           = 5 * time.Second
	defaultDialKeepAlive         = 30 * time.Second
	defaultTLSHandshakeTimeout   = 5 * time.Second
	defaultResponseHeaderTimeout = 10 * time.Second
	defaultRequestTimeout        = 10 * time.Second
)

func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:cyclop,funlen,nestif,ireturn
	baseURL := defaultCBRBaseURL
	provider := strings.ToLower(mmodel.DriverCBR.String())
	dialTimeout := defaultDialTimeout
	dialKeepAlive := defaultDialKeepAlive
	tlsHandshakeTimeout := defaultTLSHandshakeTimeout
	responseHeaderTimeout := defaultResponseHeaderTimeout
	requestTimeout := defaultRequestTimeout
	directoryPath := defaultDirectoryPath
	dailyPath := defaultDailyPath
	dynamicPath := defaultDynamicPath
	userAgent := defaultUserAgent
	acceptHeader := defaultAccept

	if settings != nil {
		if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
			baseURL = strings.TrimSpace(value)
		}
		if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
			provider = strings.TrimSpace(value)
		}
		if value, ok := settings["directory_path"].(string); ok && strings.TrimSpace(value) != "" {
			directoryPath = strings.TrimSpace(value)
		}
		if value, ok := settings["daily_path"].(string); ok && strings.TrimSpace(value) != "" {
			dailyPath = strings.TrimSpace(value)
		}
		if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
			dynamicPath = strings.TrimSpace(value)
		}

		if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
			userAgent = strings.TrimSpace(value)
		}

		if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
			acceptHeader = strings.TrimSpace(value)
		}
		dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
		dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
		tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
		responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
		requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
	}

	parsed, err := url.Parse(baseURL)
	if err != nil {
		return nil, merrors.InvalidArgumentWrap(err, "cbr: invalid base url", "base_url")
	}

	var transport http.RoundTripper = &http.Transport{
		DialContext:           (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
		TLSHandshakeTimeout:   tlsHandshakeTimeout,
		ResponseHeaderTimeout: responseHeaderTimeout,
	}

	if customTransport, ok := settings["http_round_tripper"].(http.RoundTripper); ok && customTransport != nil {
		transport = customTransport
	}

	client := &http.Client{
		Timeout:   requestTimeout,
		Transport: transport,
	}
	referer := parsed.String()

	connector := &cbrConnector{
		id:       mmodel.DriverCBR,
		provider: provider,
		http: newHTTPClient(
			logger,
			client,
			httpClientOptions{
				userAgent:     userAgent,
				accept:        acceptHeader,
				referer:       referer,
				allowedScheme: parsed.Scheme,
				allowedHost:   parsed.Host,
			},
		),
		base:          strings.TrimRight(parsed.String(), "/"),
		dailyPath:     dailyPath,
		directoryPath: directoryPath,
		dynamicPath:   dynamicPath,
		logger:        logger.Named("cbr"),
	}

	err = connector.refreshDirectory()
	if err != nil {
		return nil, err
	}

	return connector, nil
}

func (c *cbrConnector) ID() mmodel.Driver {
	return c.id
}

func (c *cbrConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
	isoCode, asOfDate, err := parseSymbol(symbol)
	if err != nil {
		return nil, err
	}

	valute, ok := c.byISO[isoCode]
	if !ok {
		return nil, merrors.InvalidArgument("cbr: unknown currency "+isoCode, "symbol")
	}

	var price string
	if asOfDate != nil {
		price, err = c.fetchHistoricalRate(ctx, valute, *asOfDate)
	} else {
		price, err = c.fetchDailyRate(ctx, valute)
	}
	if err != nil {
		return nil, err
	}

	now := time.Now().UnixMilli()

	return &mmodel.Ticker{
		Symbol:    formatSymbol(isoCode, asOfDate),
		BidPrice:  price,
		AskPrice:  price,
		Provider:  c.provider,
		Timestamp: now,
	}, nil
}

func (c *cbrConnector) refreshDirectory() error {
	endpoint, err := c.buildURL(c.directoryPath, nil)
	if err != nil {
		return err
	}

	req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint)
	if err != nil {
		return merrors.InternalWrap(err, "cbr: build directory request")
	}

	resp, err := c.http.Do(req)
	if err != nil {
		c.logger.Warn(
			"CBR directory request failed",
			zap.Error(err),
			zap.String("endpoint", endpoint),
			zap.String("referer", c.http.headerValue("Referer")),
			zap.String("user_agent", c.http.headerValue("User-Agent")),
		)
		return merrors.InternalWrap(err, "cbr: directory request failed")
	}
	defer func() {
		if closeErr := resp.Body.Close(); closeErr != nil {
			c.logger.Warn("Failed to close CBR daily response body", zap.Error(closeErr))
		}
	}()

	if resp.StatusCode != http.StatusOK {
		c.logger.Warn(
			"CBR directory returned non-OK status",
			zap.Int("status", resp.StatusCode),
			zap.String("endpoint", endpoint),
			zap.String("referer", c.http.headerValue("Referer")),
			zap.String("user_agent", c.http.headerValue("User-Agent")),
		)
		return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
	}

	decoder := xml.NewDecoder(resp.Body)
	decoder.CharsetReader = charset.NewReaderLabel

	var directory valuteDirectory

	err = decoder.Decode(&directory)
	if err != nil {
		c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
		return merrors.InternalWrap(err, "cbr: decode directory")
	}

	mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items)
	if err != nil {
		c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
		return err
	}

	c.byISO = mapping.byISO
	c.byID = mapping.byID

	return nil
}

func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
	endpoint, err := c.buildURL(c.dailyPath, nil)
	if err != nil {
		c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
		return "", err
	}

	req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
	if err != nil {
		c.logger.Warn("Failed to request daily rate", zap.Error(err), zap.String("endpoint", endpoint))
		return "", merrors.InternalWrap(err, "cbr: build daily request")
	}

	resp, err := c.http.Do(req)
	if err != nil {
		c.logger.Warn("CBR daily request failed",
			zap.Error(err), zap.String("currency", valute.ISOCharCode),
			zap.String("endpoint", endpoint), zap.String("referer", c.http.headerValue("Referer")),
			zap.String("user_agent", c.http.headerValue("User-Agent")),
		)
		return "", merrors.InternalWrap(err, "cbr: daily request failed")
	}
	defer func() {
		if closeErr := resp.Body.Close(); closeErr != nil {
			c.logger.Warn("Failed to close CBR historical response body", zap.Error(closeErr))
		}
	}()

	if resp.StatusCode != http.StatusOK {
		c.logger.Warn(
			"CBR daily returned non-OK status", zap.Int("status", resp.StatusCode),
			zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
		)
		return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
	}

	decoder := xml.NewDecoder(resp.Body)
	decoder.CharsetReader = charset.NewReaderLabel

	var payload dailyRates

	err = decoder.Decode(&payload)
	if err != nil {
		c.logger.Warn("CBR daily decode failed", zap.Error(err),
			zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
		)
		return "", merrors.InternalWrap(err, "cbr: decode daily response")
	}

	entry := payload.find(valute.ID)
	if entry == nil {
		return "", merrors.NoData("cbr: currency not found in daily rates: " + valute.ISOCharCode)
	}

	if err := validateDailyEntry(valute, entry); err != nil {
		return "", err
	}

	return computePrice(entry.Value, entry.Nominal)
}

func (c *cbrConnector) fetchHistoricalRate( //nolint:funlen
	ctx context.Context, valute valuteInfo, date time.Time,
) (string, error) {
	query := map[string]string{
		"date_req1": date.Format("02/01/2006"),
		"date_req2": date.Format("02/01/2006"),
		"VAL_NM_RQ": valute.ID,
	}
	dateStr := date.Format("2006-01-02")

	endpoint, err := c.buildURL(c.dynamicPath, query)
	if err != nil {
		return "", err
	}

	req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
	if err != nil {
		return "", merrors.InternalWrap(err, "cbr: build historical request")
	}

	resp, err := c.http.Do(req)
	if err != nil {
		c.logger.Warn(
			"CBR historical request failed",
			zap.String("currency", valute.ISOCharCode),
			zap.String("date", dateStr),
			zap.String("endpoint", endpoint),
			zap.String("referer", c.http.headerValue("Referer")),
			zap.String("user_agent", c.http.headerValue("User-Agent")),
			zap.Error(err),
		)
		return "", merrors.InternalWrap(err, "cbr: historical request failed")
	}
	defer func() {
		if closeErr := resp.Body.Close(); closeErr != nil {
			c.logger.Warn("Failed to close CBR historical response body", zap.Error(closeErr))
		}
	}()

	if resp.StatusCode != http.StatusOK {
		c.logger.Warn(
			"CBR historical returned non-OK status",
			zap.Int("status", resp.StatusCode),
			zap.String("currency", valute.ISOCharCode),
			zap.String("date", dateStr),
			zap.String("endpoint", endpoint),
		)
		return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
	}

	decoder := xml.NewDecoder(resp.Body)
	decoder.CharsetReader = charset.NewReaderLabel

	var payload dynamicRates
	if err := decoder.Decode(&payload); err != nil {
		c.logger.Warn(
			"CBR historical decode failed",
			zap.String("currency", valute.ISOCharCode),
			zap.String("date", dateStr),
			zap.String("endpoint", endpoint),
			zap.Error(err),
		)
		return "", merrors.InternalWrap(err, "cbr: decode historical response")
	}

	record := payload.find(valute.ID, date)
	if record == nil {
		return "", merrors.NoData("cbr: historical rate not found for " + valute.ISOCharCode)
	}

	if record.Nominal != "" {
		nominal, err := parseNominal(record.Nominal)
		if err != nil {
			return "", merrors.InvalidDataType(err.Error())
		}
		if nominal != valute.Nominal {
			return "", merrors.Internal("cbr: historical nominal mismatch for " + valute.ISOCharCode)
		}
	}

	return computePrice(record.Value, strconv.FormatInt(valute.Nominal, 10))
}

func (c *cbrConnector) buildURL(path string, query map[string]string) (string, error) {
	base, err := url.Parse(c.base)
	if err != nil {
		return "", merrors.InternalWrap(err, "cbr: parse base url")
	}

	base.Path = strings.TrimRight(base.Path, "/") + path

	q := base.Query()
	for key, value := range query {
		q.Set(key, value)
	}
	base.RawQuery = q.Encode()
	return base.String(), nil
}

type valuteDirectory struct {
	Items []valuteItem `xml:"Item"`
}

type valuteItem struct {
	ID         string `xml:"ID,attr"`
	ISOChar    string `xml:"ISO_Char_Code"`
	ISONum     string `xml:"ISO_Num_Code"`
	Name       string `xml:"Name"`
	EngName    string `xml:"EngName"`
	NominalStr string `xml:"Nominal"`
}

type valuteInfo struct {
	ID          string
	ISOCharCode string
	ISONumCode  string
	Name        string
	EngName     string
	Nominal     int64
}

type valuteMapping struct {
	byISO map[string]valuteInfo
	byID  map[string]valuteInfo
}

func buildValuteMapping(logger mlogger.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:cyclop,gocognit,nestif
	byISO := make(map[string]valuteInfo, len(items))
	byID := make(map[string]valuteInfo, len(items))
	byNum := make(map[string]string, len(items))

	for _, item := range items {
		valuteID := strings.TrimSpace(item.ID)
		isoChar := strings.ToUpper(strings.TrimSpace(item.ISOChar))
		isoNum := strings.TrimSpace(item.ISONum)
		name := strings.TrimSpace(item.Name)
		engName := strings.TrimSpace(item.EngName)

		nominal, err := parseNominal(item.NominalStr)
		if err != nil {
			return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
		}

		if valuteID == "" || isoChar == "" {
			logger.Info("Skipping invalid currency entry",
				zap.String("id", valuteID),
				zap.String("iso_char", isoChar),
				zap.String("name", name),
			)

			continue
		}

		info := valuteInfo{
			ID:          valuteID,
			ISOCharCode: isoChar,
			ISONumCode:  isoNum,
			Name:        name,
			EngName:     engName,
			Nominal:     nominal,
		}

		// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
		if existing, ok := byISO[isoChar]; ok {
			// Same ISO + same ID: duplicate entry, just ignore.
			if existing.ID == valuteID {
				logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
					zap.String("iso_code", isoChar),
					zap.String("id", valuteID),
				)

				continue
			}

			// Different IDs but same ISO char.
			// Choose canonical entry:
			//  1) Prefer nominal == 1
			//  2) Otherwise prefer smaller nominal
			keepExisting := true

			switch {
			case existing.Nominal != 1 && info.Nominal == 1:
				keepExisting = false
			case existing.Nominal == 1 && info.Nominal != 1:
				keepExisting = true
			case info.Nominal < existing.Nominal:
				keepExisting = false
			}

			if keepExisting {
				logger.Warn("Ignoring duplicate ISO currency entry with less preferred nominal",
					zap.String("iso_code", isoChar),
					zap.String("existing_id", existing.ID),
					zap.Int64("existing_nominal", existing.Nominal),
					zap.String("new_id", info.ID),
					zap.Int64("new_nominal", info.Nominal),
				)
				// We keep the old one, just skip the new.
				continue
			}

			// Replace existing mapping with the new, more canonical one.
			logger.Warn("Replacing currency mapping due to more canonical nominal",
				zap.String("iso_code", isoChar),
				zap.String("old_id", existing.ID),
				zap.Int64("old_nominal", existing.Nominal),
				zap.String("new_id", info.ID),
				zap.Int64("new_nominal", info.Nominal),
			)

			// Update byID: drop old ID, add new one
			delete(byID, existing.ID)
			byID[valuteID] = info

			// Update ISO mapping
			byISO[isoChar] = info

			// Update numeric-code index if present
			if isoNum != "" {
				if existingID, ok := byNum[isoNum]; ok && existingID != valuteID {
					return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
				}

				byNum[isoNum] = valuteID
			}

			continue
		}

		// No existing ISO entry, do normal uniqueness checks.

		if existing, ok := byID[valuteID]; ok && existing.ISOCharCode != isoChar {
			return nil, merrors.InvalidDataType("cbr: duplicate valute id " + valuteID)
		}

		if isoNum != "" {
			if existingID, ok := byNum[isoNum]; ok && existingID != valuteID {
				return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
			}

			byNum[isoNum] = valuteID
		}

		logger.Info("Installing currency code",
			zap.String("iso_code", isoChar), zap.String("id", valuteID), zap.Int64("nominal", nominal),
		)

		byISO[isoChar] = info
		byID[valuteID] = info
	}

	if len(byISO) == 0 {
		return nil, merrors.InvalidDataType("cbr: empty directory received")
	}

	return &valuteMapping{
		byISO: byISO,
		byID:  byID,
	}, nil
}

type dailyRates struct {
	Valutes []dailyValute `xml:"Valute"`
}

type dailyValute struct {
	ID       string `xml:"ID,attr"`
	NumCode  string `xml:"NumCode"`
	CharCode string `xml:"CharCode"`
	Nominal  string `xml:"Nominal"`
	Name     string `xml:"Name"`
	Value    string `xml:"Value"`
}

func (d *dailyRates) find(id string) *dailyValute {
	if d == nil {
		return nil
	}

	for idx := range d.Valutes {
		if strings.EqualFold(strings.TrimSpace(d.Valutes[idx].ID), id) {
			return &d.Valutes[idx]
		}
	}
	return nil
}

type dynamicRates struct {
	Records []dynamicRecord `xml:"Record"`
}

type dynamicRecord struct {
	ID      string `xml:"Id,attr"`
	DateRaw string `xml:"Date,attr"`
	Nominal string `xml:"Nominal"`
	Value   string `xml:"Value"`
}

func (d *dynamicRates) find(id string, date time.Time) *dynamicRecord {
	if d == nil {
		return nil
	}

	target := date.Format("02.01.2006")

	for idx := range d.Records {
		rec := &d.Records[idx]
		if !strings.EqualFold(strings.TrimSpace(rec.ID), id) {
			continue
		}
		if strings.TrimSpace(rec.DateRaw) == target {
			return rec
		}
	}
	return nil
}

func validateDailyEntry(expected valuteInfo, entry *dailyValute) error {
	if entry == nil {
		return merrors.NoData("cbr: missing daily entry")
	}
	if !strings.EqualFold(strings.TrimSpace(entry.CharCode), expected.ISOCharCode) {
		return merrors.Internal("cbr: char code mismatch for " + expected.ISOCharCode)
	}
	if expected.ISONumCode != "" && strings.TrimSpace(entry.NumCode) != expected.ISONumCode {
		return merrors.Internal("cbr: iso numeric mismatch for " + expected.ISOCharCode)
	}
	if expected.Name != "" && strings.TrimSpace(entry.Name) != expected.Name {
		return merrors.Internal("cbr: currency name mismatch for " + expected.ISOCharCode)
	}

	nominal, err := parseNominal(entry.Nominal)
	if err != nil {
		return merrors.InvalidDataType("cbr: parse daily nominal: " + err.Error())
	}
	if nominal != expected.Nominal {
		return merrors.Internal("cbr: nominal mismatch for " + expected.ISOCharCode)
	}
	return nil
}

func parseSymbol(symbol string) (string, *time.Time, error) {
	trimmed := strings.TrimSpace(symbol)
	if trimmed == "" {
		return "", nil, merrors.InvalidArgument("cbr: symbol is empty", "symbol")
	}

	parts := strings.Split(trimmed, "@")
	if len(parts) > 2 {
		return "", nil, merrors.InvalidArgument("cbr: invalid symbol format", "symbol")
	}

	iso := strings.ToUpper(strings.TrimSpace(parts[0]))
	if len(iso) != 3 {
		return "", nil, merrors.InvalidArgument("cbr: symbol must be ISO currency code", "symbol")
	}

	if len(parts) == 1 {
		return iso, nil, nil
	}

	datePart := strings.TrimSpace(parts[1])
	if datePart == "" {
		return "", nil, merrors.InvalidArgument("cbr: date component is empty", "symbol")
	}

	parsed, err := time.Parse("2006-01-02", datePart)
	if err != nil {
		return "", nil, merrors.InvalidArgumentWrap(err, "cbr: invalid date component", "symbol")
	}

	return iso, &parsed, nil
}

func parseNominal(value string) (int64, error) {
	nominal, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
	if err != nil || nominal <= 0 {
		return 0, merrors.InvalidDataType("cbr: invalid nominal \"" + value + "\"")
	}
	return nominal, nil
}

func computePrice(value string, nominalStr string) (string, error) {
	raw := strings.ReplaceAll(strings.TrimSpace(value), " ", "")
	raw = strings.ReplaceAll(raw, ",", ".")

	r := new(big.Rat)
	if _, ok := r.SetString(raw); !ok {
		return "", merrors.InvalidDataType("invalid decimal \"" + value + "\"")
	}

	nominal, err := parseNominal(nominalStr)
	if err != nil {
		return "", err
	}

	den := big.NewRat(nominal, 1)
	price := new(big.Rat).Quo(r, den)
	return price.FloatString(8), nil //nolint:mnd
}

func formatSymbol(iso string, asOf *time.Time) string {
	if asOf == nil {
		return iso
	}
	return iso + "@" + asOf.Format("2006-01-02")
}
