package client

import (
	"context"
	"crypto/tls"
	"fmt"
	"strings"
	"time"

	"github.com/tech/sendico/pkg/discovery"
	"github.com/tech/sendico/pkg/ledgerconv"
	"github.com/tech/sendico/pkg/merrors"
	"github.com/tech/sendico/pkg/model/account_role"
	"github.com/tech/sendico/pkg/payments/rail"
	describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
	moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
	paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
	connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
	ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/protobuf/types/known/structpb"
	"google.golang.org/protobuf/types/known/timestamppb"
)

const (
	ledgerConnectorID = "ledger"
	ledgerRailName    = discovery.RailLedger

	opParamOperation              = "operation"
	opParamToMoney                = "to_money"
	opParamAmount                 = "amount"
	opParamCurrency               = "currency"
	opParamOrganizationRef        = "organization_ref"
	opParamAccountType            = "account_type"
	opParamStatus                 = "status"
	opParamAllowNegative          = "allow_negative"
	opParamRole                   = "role"
	opParamDescription            = "description"
	opParamMetadata               = "metadata"
	opParamCharges                = "charges"
	opParamEventTime              = "event_time"
	opParamContraLedgerAccountRef = "contra_ledger_account_ref"
	opParamLedgerAccountRef       = "ledger_account_ref"
	opParamLineType               = "line_type"
	opParamAccountCode            = "account_code"
	opParamIsSettlement           = "is_settlement"

	txMetaPaymentPlanID     = "payment_plan_id"
	txMetaFromRail          = "from_rail"
	txMetaToRail            = "to_rail"
	txMetaExternalReference = "external_reference_id"
	txMetaFXRateUsed        = "fx_rate_used"
	txMetaFeeAmount         = "fee_amount"
)

// Client exposes typed helpers around the ledger gRPC API.
type Client interface {
	ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error)
	CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error)
	HoldBalance(ctx context.Context, accountID string, amount string) error

	CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
	ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
	ListConnectorAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error)
	PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
	PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
	PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
	PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
	TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
	ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error)

	BlockAccount(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error)
	UnblockAccount(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error)

	GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error)
	GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error)
	GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error)

	Close() error
}

type grpcConnectorClient interface {
	OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error)
	GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
	ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error)
	GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
	UpdateAccountState(ctx context.Context, in *connectorv1.UpdateAccountStateRequest, opts ...grpc.CallOption) (*connectorv1.UpdateAccountStateResponse, error)
	SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
	GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
	ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error)
}

type ledgerClient struct {
	cfg    Config
	conn   *grpc.ClientConn
	client grpcConnectorClient
}

// New dials the ledger endpoint and returns a ready client.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
	cfg.setDefaults()
	if strings.TrimSpace(cfg.Address) == "" {
		return nil, merrors.InvalidArgument("ledger: address is required")
	}

	dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
	dialOpts = append(dialOpts, opts...)

	if cfg.Insecure {
		dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
	} else {
		dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
	}

	conn, err := grpc.NewClient(cfg.Address, dialOpts...)
	if err != nil {
		return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Address))
	}

	return &ledgerClient{
		cfg:    cfg,
		conn:   conn,
		client: connectorv1.NewConnectorServiceClient(conn),
	}, nil
}

// NewWithClient injects a pre-built ledger client (useful for tests).
func NewWithClient(cfg Config, lc grpcConnectorClient) Client {
	cfg.setDefaults()
	return &ledgerClient{
		cfg:    cfg,
		client: lc,
	}
}

func (c *ledgerClient) Close() error {
	if c.conn != nil {
		return c.conn.Close()
	}
	return nil
}

func (c *ledgerClient) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) {
	if strings.TrimSpace(accountID) == "" {
		return nil, merrors.InvalidArgument("ledger: account_id is required")
	}
	resp, err := c.GetBalance(ctx, &ledgerv1.GetBalanceRequest{LedgerAccountRef: strings.TrimSpace(accountID)})
	if err != nil {
		return nil, err
	}
	if resp == nil || resp.GetBalance() == nil {
		return nil, merrors.Internal("ledger: balance response missing")
	}
	return cloneMoney(resp.GetBalance()), nil
}

func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) {
	orgRef := strings.TrimSpace(tx.OrganizationRef)
	if orgRef == "" {
		return "", merrors.InvalidArgument("ledger: organization_ref is required")
	}
	accountRef := strings.TrimSpace(tx.LedgerAccountRef)
	if accountRef == "" {
		return "", merrors.InvalidArgument("ledger: ledger_account_ref is required")
	}
	money := &moneyv1.Money{
		Currency: strings.TrimSpace(tx.Currency),
		Amount:   strings.TrimSpace(tx.Amount),
	}
	if money.GetCurrency() == "" || money.GetAmount() == "" {
		return "", merrors.InvalidArgument("ledger: amount is required")
	}
	tx = normalizeLedgerTxRails(tx)

	description := strings.TrimSpace(tx.Description)
	metadata := ledgerTxMetadata(tx.Metadata, tx)
	extraParams := map[string]interface{}{}
	if op := strings.TrimSpace(tx.Operation); op != "" {
		extraParams[opParamOperation] = op
	}
	if len(extraParams) == 0 {
		extraParams = nil
	}

	switch {
	case isLedgerRail(tx.FromRail) && !isLedgerRail(tx.ToRail):
		resp, err := c.submitLedgerOperationWithExtras(ctx, connectorv1.OperationType_DEBIT, accountRef, "", money, &ledgerv1.PostDebitRequest{
			IdempotencyKey:         strings.TrimSpace(tx.IdempotencyKey),
			OrganizationRef:        orgRef,
			LedgerAccountRef:       accountRef,
			Money:                  money,
			Description:            description,
			Charges:                tx.Charges,
			Metadata:               metadata,
			ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
		}, extraParams)
		if err != nil {
			return "", err
		}
		return strings.TrimSpace(resp.GetJournalEntryRef()), nil
	case isLedgerRail(tx.ToRail) && !isLedgerRail(tx.FromRail):
		resp, err := c.submitLedgerOperationWithExtras(ctx, connectorv1.OperationType_CREDIT, "", accountRef, money, &ledgerv1.PostCreditRequest{
			IdempotencyKey:         strings.TrimSpace(tx.IdempotencyKey),
			OrganizationRef:        orgRef,
			LedgerAccountRef:       accountRef,
			Money:                  money,
			Description:            description,
			Charges:                tx.Charges,
			Metadata:               metadata,
			ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
		}, extraParams)
		if err != nil {
			return "", err
		}
		return strings.TrimSpace(resp.GetJournalEntryRef()), nil
	default:
		return "", merrors.InvalidArgument("ledger: unsupported transaction direction")
	}
}

func (c *ledgerClient) HoldBalance(ctx context.Context, accountID string, amount string) error {
	return merrors.NotImplemented("ledger: hold balance not supported")
}

func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil {
		return nil, merrors.InvalidArgument("ledger: request is required")
	}
	if strings.TrimSpace(req.GetCurrency()) == "" {
		return nil, merrors.InvalidArgument("ledger: currency is required")
	}
	params := map[string]interface{}{
		opParamOrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
		opParamAccountType:     req.GetAccountType().String(),
		opParamStatus:          req.GetStatus().String(),
		opParamAllowNegative:   req.GetAllowNegative(),
	}
	if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
		params[opParamRole] = role.String()
	}
	label := ""
	if desc := req.GetDescribable(); desc != nil {
		label = strings.TrimSpace(desc.GetName())
		if desc.Description != nil {
			trimmed := strings.TrimSpace(desc.GetDescription())
			if trimmed != "" {
				params[opParamDescription] = trimmed
			}
		}
	}
	if len(req.GetMetadata()) > 0 {
		params[opParamMetadata] = mapStringToInterface(req.GetMetadata())
	}
	resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{
		Kind:   connectorv1.AccountKind_LEDGER_ACCOUNT,
		Asset:  strings.TrimSpace(req.GetCurrency()),
		Label:  label,
		Params: structFromMap(params),
	})
	if err != nil {
		return nil, err
	}
	if resp.GetError() != nil {
		return nil, connectorError(resp.GetError())
	}
	return &ledgerv1.CreateAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
}

func (c *ledgerClient) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil || strings.TrimSpace(req.GetOrganizationRef()) == "" {
		return nil, merrors.InvalidArgument("ledger: organization_ref is required")
	}
	resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{
		OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
		OwnerRefFilter:  req.GetOwnerRefFilter(),
	})
	if err != nil {
		return nil, err
	}
	accounts := make([]*ledgerv1.LedgerAccount, 0, len(resp.GetAccounts()))
	for _, account := range resp.GetAccounts() {
		accounts = append(accounts, ledgerAccountFromConnector(account))
	}
	return &ledgerv1.ListAccountsResponse{Accounts: accounts}, nil
}

func (c *ledgerClient) ListConnectorAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil {
		return nil, merrors.InvalidArgument("ledger: request is required")
	}
	return c.client.ListAccounts(ctx, req)
}

func (c *ledgerClient) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
	return c.submitLedgerOperation(ctx, connectorv1.OperationType_CREDIT, "", req.GetLedgerAccountRef(), req.GetMoney(), req)
}

func (c *ledgerClient) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
	return c.submitLedgerOperation(ctx, connectorv1.OperationType_DEBIT, req.GetLedgerAccountRef(), "", req.GetMoney(), req)
}

func (c *ledgerClient) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
	return c.submitLedgerOperationWithExtras(
		ctx,
		connectorv1.OperationType_CREDIT,
		"",
		req.GetLedgerAccountRef(),
		req.GetMoney(),
		req,
		map[string]interface{}{opParamOperation: discovery.OperationExternalCredit},
	)
}

func (c *ledgerClient) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
	return c.submitLedgerOperationWithExtras(
		ctx,
		connectorv1.OperationType_DEBIT,
		req.GetLedgerAccountRef(),
		"",
		req.GetMoney(),
		req,
		map[string]interface{}{opParamOperation: discovery.OperationExternalDebit},
	)
}

func (c *ledgerClient) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
	return c.submitLedgerOperation(ctx, connectorv1.OperationType_TRANSFER, req.GetFromLedgerAccountRef(), req.GetToLedgerAccountRef(), req.GetMoney(), req)
}

func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil {
		return nil, merrors.InvalidArgument("ledger: request is required")
	}
	if req.GetFromMoney() == nil || req.GetToMoney() == nil {
		return nil, merrors.InvalidArgument("ledger: from_money and to_money are required")
	}
	params := ledgerOperationParams(req.GetOrganizationRef(), req.GetDescription(), req.GetMetadata(), req.GetCharges(), req.GetEventTime())
	params["rate"] = strings.TrimSpace(req.GetRate())
	params[opParamToMoney] = map[string]interface{}{opParamAmount: req.GetToMoney().GetAmount(), opParamCurrency: req.GetToMoney().GetCurrency()}
	operation := &connectorv1.Operation{
		Type:           connectorv1.OperationType_FX,
		IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
		From:           accountParty(req.GetFromLedgerAccountRef()),
		To:             accountParty(req.GetToLedgerAccountRef()),
		Money:          req.GetFromMoney(),
		Params:         structFromMap(params),
	}
	resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
	if err != nil {
		return nil, err
	}
	if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
		return nil, connectorError(resp.GetReceipt().GetError())
	}
	return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: ledgerv1.EntryType_ENTRY_FX}, nil
}

func (c *ledgerClient) BlockAccount(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
		return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
	}
	sourceRole := account_role.ToProto(accountRoleFromLedgerProto(req.GetRole()))
	resp, err := c.client.UpdateAccountState(ctx, &connectorv1.UpdateAccountStateRequest{
		AccountRef:  &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
		TargetState: connectorv1.AccountState_ACCOUNT_SUSPENDED,
		SourceRole:  sourceRole,
	})
	if err != nil {
		return nil, err
	}
	if resp.GetError() != nil {
		return nil, connectorError(resp.GetError())
	}
	return &ledgerv1.BlockAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
}

func (c *ledgerClient) UnblockAccount(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
		return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
	}
	sourceRole := account_role.ToProto(accountRoleFromLedgerProto(req.GetRole()))
	resp, err := c.client.UpdateAccountState(ctx, &connectorv1.UpdateAccountStateRequest{
		AccountRef:  &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
		TargetState: connectorv1.AccountState_ACCOUNT_ACTIVE,
		SourceRole:  sourceRole,
	})
	if err != nil {
		return nil, err
	}
	if resp.GetError() != nil {
		return nil, connectorError(resp.GetError())
	}
	return &ledgerv1.UnblockAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
}

func (c *ledgerClient) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
		return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
	}
	resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())}})
	if err != nil {
		return nil, err
	}
	balance := resp.GetBalance()
	if balance == nil {
		return nil, merrors.Internal("ledger: balance response missing")
	}
	return &ledgerv1.BalanceResponse{
		LedgerAccountRef: strings.TrimSpace(req.GetLedgerAccountRef()),
		Balance:          balance.GetAvailable(),
		LastUpdated:      balance.GetCalculatedAt(),
	}, nil
}

func (c *ledgerClient) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil || strings.TrimSpace(req.GetEntryRef()) == "" {
		return nil, merrors.InvalidArgument("ledger: entry_ref is required")
	}
	resp, err := c.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetEntryRef())})
	if err != nil {
		return nil, err
	}
	return journalEntryFromOperation(resp.GetOperation()), nil
}

func (c *ledgerClient) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
		return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
	}
	resp, err := c.client.ListOperations(ctx, &connectorv1.ListOperationsRequest{
		AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
		Page:       pageFromStatement(req),
	})
	if err != nil {
		return nil, err
	}
	entries := make([]*ledgerv1.JournalEntryResponse, 0, len(resp.GetOperations()))
	for _, op := range resp.GetOperations() {
		entries = append(entries, journalEntryFromOperation(op))
	}
	nextCursor := ""
	if resp.GetPage() != nil {
		nextCursor = resp.GetPage().GetNextCursor()
	}
	return &ledgerv1.StatementResponse{Entries: entries, NextCursor: nextCursor}, nil
}

func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connectorv1.OperationType, fromRef, toRef string, money *moneyv1.Money, req interface{}) (*ledgerv1.PostResponse, error) {
	return c.submitLedgerOperationWithExtras(ctx, opType, fromRef, toRef, money, req, nil)
}

func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opType connectorv1.OperationType, fromRef, toRef string, money *moneyv1.Money, req interface{}, extraParams map[string]interface{}) (*ledgerv1.PostResponse, error) {
	ctx, cancel := c.callContext(ctx)
	defer cancel()
	if money == nil {
		return nil, merrors.InvalidArgument("ledger: money is required")
	}

	var (
		idempotencyKey string
		orgRef         string
		description    string
		metadata       map[string]string
		charges        []*ledgerv1.PostingLine
		eventTime      *timestamppb.Timestamp
		contraRef      string
		fromRole       account_role.AccountRole
		toRole         account_role.AccountRole
	)

	switch r := req.(type) {
	case *ledgerv1.PostCreditRequest:
		idempotencyKey = r.GetIdempotencyKey()
		orgRef = r.GetOrganizationRef()
		description = r.GetDescription()
		metadata = r.GetMetadata()
		charges = r.GetCharges()
		eventTime = r.GetEventTime()
		contraRef = r.GetContraLedgerAccountRef()
		toRole = accountRoleFromLedgerProto(r.GetRole())
	case *ledgerv1.PostDebitRequest:
		idempotencyKey = r.GetIdempotencyKey()
		orgRef = r.GetOrganizationRef()
		description = r.GetDescription()
		metadata = r.GetMetadata()
		charges = r.GetCharges()
		eventTime = r.GetEventTime()
		contraRef = r.GetContraLedgerAccountRef()
		fromRole = accountRoleFromLedgerProto(r.GetRole())
	case *ledgerv1.TransferRequest:
		idempotencyKey = r.GetIdempotencyKey()
		orgRef = r.GetOrganizationRef()
		description = r.GetDescription()
		metadata = r.GetMetadata()
		charges = r.GetCharges()
		eventTime = r.GetEventTime()
		fromRole = accountRoleFromLedgerProto(r.GetFromRole())
		toRole = accountRoleFromLedgerProto(r.GetToRole())
	}

	params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime)
	if contraRef != "" {
		params[opParamContraLedgerAccountRef] = strings.TrimSpace(contraRef)
	}
	if len(extraParams) > 0 {
		for key, value := range extraParams {
			params[key] = value
		}
	}

	op := &connectorv1.Operation{
		Type:           opType,
		IdempotencyKey: strings.TrimSpace(idempotencyKey),
		Money:          money,
		Params:         structFromMap(params),
	}
	if fromRef != "" {
		op.From = accountParty(fromRef)
	}
	if toRef != "" {
		op.To = accountParty(toRef)
	}
	if fromRole != "" {
		op.FromRole = account_role.ToProto(fromRole)
	}
	if toRole != "" {
		op.ToRole = account_role.ToProto(toRole)
	}

	resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: op})
	if err != nil {
		return nil, err
	}
	if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
		return nil, connectorError(resp.GetReceipt().GetError())
	}
	return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: entryTypeFromOperation(opType)}, nil
}

func accountRoleFromLedgerProto(role ledgerv1.AccountRole) account_role.AccountRole {
	switch role {
	case ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING:
		return account_role.AccountRoleOperating
	case ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD:
		return account_role.AccountRoleHold
	case ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT:
		return account_role.AccountRoleTransit
	case ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT:
		return account_role.AccountRoleSettlement
	case ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING:
		return account_role.AccountRoleClearing
	case ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING:
		return account_role.AccountRolePending
	case ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE:
		return account_role.AccountRoleReserve
	case ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY:
		return account_role.AccountRoleLiquidity
	case ledgerv1.AccountRole_ACCOUNT_ROLE_FEE:
		return account_role.AccountRoleFee
	case ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK:
		return account_role.AccountRoleChargeback
	case ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT:
		return account_role.AccountRoleAdjustment
	default:
		return ""
	}
}

func ledgerOperationParams(orgRef, description string, metadata map[string]string, charges []*ledgerv1.PostingLine, eventTime *timestamppb.Timestamp) map[string]interface{} {
	params := map[string]interface{}{
		opParamOrganizationRef: strings.TrimSpace(orgRef),
		opParamDescription:     strings.TrimSpace(description),
	}
	if len(metadata) > 0 {
		params[opParamMetadata] = mapStringToInterface(metadata)
	}
	if len(charges) > 0 {
		params[opParamCharges] = chargesToInterface(charges)
	}
	if eventTime != nil {
		params[opParamEventTime] = eventTime.AsTime().UTC().Format(time.RFC3339Nano)
	}
	return params
}

func accountParty(accountRef string) *connectorv1.OperationParty {
	if strings.TrimSpace(accountRef) == "" {
		return nil
	}
	return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(accountRef)}}}
}

func entryTypeFromOperation(opType connectorv1.OperationType) ledgerv1.EntryType {
	switch opType {
	case connectorv1.OperationType_CREDIT:
		return ledgerv1.EntryType_ENTRY_CREDIT
	case connectorv1.OperationType_DEBIT:
		return ledgerv1.EntryType_ENTRY_DEBIT
	case connectorv1.OperationType_TRANSFER:
		return ledgerv1.EntryType_ENTRY_TRANSFER
	case connectorv1.OperationType_FX:
		return ledgerv1.EntryType_ENTRY_FX
	default:
		return ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED
	}
}

func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAccount {
	if account == nil {
		return nil
	}
	details := map[string]interface{}{}
	if account.GetProviderDetails() != nil {
		details = account.GetProviderDetails().AsMap()
	}
	accountType := ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
	if v := strings.TrimSpace(fmt.Sprint(details[opParamAccountType])); v != "" {
		accountType = parseAccountType(v)
	}
	status := ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
	if v := strings.TrimSpace(fmt.Sprint(details[opParamStatus])); v != "" {
		status = parseAccountStatus(v)
	}
	allowNegative := false
	if v, ok := details[opParamAllowNegative].(bool); ok {
		allowNegative = v
	}
	role := ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
	if v := strings.TrimSpace(fmt.Sprint(details[opParamRole])); v != "" {
		if parsed, ok := ledgerconv.ParseAccountRole(v); ok {
			role = parsed
		}
	}
	if role == ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
		switch v := details[opParamIsSettlement].(type) {
		case bool:
			if v {
				role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
			}
		case string:
			if strings.EqualFold(strings.TrimSpace(v), "true") {
				role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
			}
		}
	}
	accountCode := strings.TrimSpace(fmt.Sprint(details[opParamAccountCode]))
	accountID := ""
	if ref := account.GetRef(); ref != nil {
		accountID = strings.TrimSpace(ref.GetAccountId())
	}
	organizationRef := strings.TrimSpace(account.GetOwnerRef())
	if v := strings.TrimSpace(fmt.Sprint(details[opParamOrganizationRef])); v != "" {
		organizationRef = v
	}
	describable := account.GetDescribable()
	label := strings.TrimSpace(account.GetLabel())
	if describable == nil && label != "" {
		describable = &describablev1.Describable{Name: label}
	} else if describable != nil && strings.TrimSpace(describable.GetName()) == "" && label != "" {
		desc := strings.TrimSpace(describable.GetDescription())
		if desc == "" {
			describable = &describablev1.Describable{Name: label}
		} else {
			describable = &describablev1.Describable{Name: label, Description: &desc}
		}
	}
	return &ledgerv1.LedgerAccount{
		LedgerAccountRef: accountID,
		OrganizationRef:  organizationRef,
		AccountCode:      accountCode,
		AccountType:      accountType,
		Currency:         strings.TrimSpace(account.GetAsset()),
		Status:           status,
		AllowNegative:    allowNegative,
		Role:             role,
		CreatedAt:        account.GetCreatedAt(),
		UpdatedAt:        account.GetUpdatedAt(),
		Describable:      describable,
	}
}

func parseAccountType(value string) ledgerv1.AccountType {
	accountType, _ := ledgerconv.ParseAccountType(value)
	return accountType
}

func parseAccountStatus(value string) ledgerv1.AccountStatus {
	status, _ := ledgerconv.ParseAccountStatus(value)
	return status
}

func journalEntryFromOperation(op *connectorv1.Operation) *ledgerv1.JournalEntryResponse {
	if op == nil {
		return nil
	}
	entry := &ledgerv1.JournalEntryResponse{
		EntryRef:          strings.TrimSpace(op.GetOperationId()),
		EntryType:         entryTypeFromOperation(op.GetType()),
		Description:       operationDescription(op),
		EventTime:         op.GetCreatedAt(),
		Lines:             postingLinesFromOperation(op),
		LedgerAccountRefs: ledgerAccountRefsFromOperation(op),
	}
	return entry
}

func operationDescription(op *connectorv1.Operation) string {
	if op == nil || op.GetParams() == nil {
		return ""
	}
	if value, ok := op.GetParams().AsMap()[opParamDescription]; ok {
		return strings.TrimSpace(fmt.Sprint(value))
	}
	return ""
}

func postingLinesFromOperation(op *connectorv1.Operation) []*ledgerv1.PostingLine {
	if op == nil || op.GetMoney() == nil {
		return nil
	}
	lines := []*ledgerv1.PostingLine{}
	if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
		lines = append(lines, &ledgerv1.PostingLine{LedgerAccountRef: strings.TrimSpace(from.GetAccount().GetAccountId()), Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
	}
	if to := op.GetTo(); to != nil && to.GetAccount() != nil {
		lines = append(lines, &ledgerv1.PostingLine{LedgerAccountRef: strings.TrimSpace(to.GetAccount().GetAccountId()), Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
	}
	if len(lines) == 0 {
		lines = append(lines, &ledgerv1.PostingLine{Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
	}
	return lines
}

func ledgerAccountRefsFromOperation(op *connectorv1.Operation) []string {
	refs := []string{}
	if op == nil {
		return refs
	}
	if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
		refs = append(refs, strings.TrimSpace(from.GetAccount().GetAccountId()))
	}
	if to := op.GetTo(); to != nil && to.GetAccount() != nil {
		refs = append(refs, strings.TrimSpace(to.GetAccount().GetAccountId()))
	}
	return refs
}

func pageFromStatement(req *ledgerv1.GetStatementRequest) *paginationv1.CursorPageRequest {
	if req == nil {
		return nil
	}
	return &paginationv1.CursorPageRequest{
		Cursor: strings.TrimSpace(req.GetCursor()),
		Limit:  req.GetLimit(),
	}
}

func chargesToInterface(charges []*ledgerv1.PostingLine) []interface{} {
	if len(charges) == 0 {
		return nil
	}
	result := make([]interface{}, 0, len(charges))
	for _, line := range charges {
		if line == nil || line.GetMoney() == nil {
			continue
		}
		result = append(result, map[string]interface{}{
			opParamLedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
			opParamAmount:           strings.TrimSpace(line.GetMoney().GetAmount()),
			opParamCurrency:         strings.TrimSpace(line.GetMoney().GetCurrency()),
			opParamLineType:         line.GetLineType().String(),
		})
	}
	if len(result) == 0 {
		return nil
	}
	return result
}

func connectorError(err *connectorv1.ConnectorError) error {
	if err == nil {
		return nil
	}
	msg := strings.TrimSpace(err.GetMessage())
	switch err.GetCode() {
	case connectorv1.ErrorCode_INVALID_PARAMS:
		return merrors.InvalidArgument(msg)
	case connectorv1.ErrorCode_NOT_FOUND:
		return merrors.NoData(msg)
	case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
		return merrors.NotImplemented(msg)
	case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
		return merrors.Internal(msg)
	default:
		return merrors.Internal(msg)
	}
}

func structFromMap(data map[string]interface{}) *structpb.Struct {
	if len(data) == 0 {
		return nil
	}
	result, err := structpb.NewStruct(data)
	if err != nil {
		return nil
	}
	return result
}

func mapStringToInterface(input map[string]string) map[string]interface{} {
	if len(input) == 0 {
		return nil
	}
	out := make(map[string]interface{}, len(input))
	for k, v := range input {
		out[k] = v
	}
	return out
}

func (c *ledgerClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
	timeout := c.cfg.CallTimeout
	if timeout <= 0 {
		timeout = 3 * time.Second
	}
	return context.WithTimeout(ctx, timeout) //nolint:gosec // cancel func is always invoked by call sites
}

func isLedgerRail(value string) bool {
	return normalizeRail(value) == ledgerRailName
}

func normalizeLedgerTxRails(tx rail.LedgerTx) rail.LedgerTx {
	tx.FromRail = normalizeRail(tx.FromRail)
	tx.ToRail = normalizeRail(tx.ToRail)
	return tx
}

func normalizeRail(value string) string {
	clean := discovery.NormalizeRail(value)
	switch clean {
	case "RAIL_CRYPTO":
		return discovery.RailCrypto
	case "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT":
		return discovery.RailProviderSettlement
	case "RAIL_LEDGER":
		return discovery.RailLedger
	case "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT":
		return discovery.RailCardPayout
	case "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP":
		return discovery.RailFiatOnRamp
	case "FIAT_OFFRAMP", "RAIL_OFFRAMP", "RAIL_FIAT_OFFRAMP":
		return discovery.RailFiatOffRamp
	default:
		return clean
	}
}

func cloneMoney(input *moneyv1.Money) *moneyv1.Money {
	if input == nil {
		return nil
	}
	return &moneyv1.Money{
		Currency: input.GetCurrency(),
		Amount:   input.GetAmount(),
	}
}

func cloneMetadata(input map[string]string) map[string]string {
	if len(input) == 0 {
		return nil
	}
	out := make(map[string]string, len(input))
	for k, v := range input {
		out[k] = v
	}
	return out
}

func ledgerTxMetadata(base map[string]string, tx rail.LedgerTx) map[string]string {
	meta := cloneMetadata(base)
	if meta == nil {
		meta = map[string]string{}
	}
	if val := strings.TrimSpace(tx.PaymentPlanID); val != "" {
		meta[txMetaPaymentPlanID] = val
	}
	if val := normalizeRail(tx.FromRail); val != "" {
		meta[txMetaFromRail] = val
	}
	if val := normalizeRail(tx.ToRail); val != "" {
		meta[txMetaToRail] = val
	}
	if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" {
		meta[txMetaExternalReference] = val
	}
	if val := strings.TrimSpace(tx.FXRateUsed); val != "" {
		meta[txMetaFXRateUsed] = val
	}
	if val := strings.TrimSpace(tx.FeeAmount); val != "" {
		meta[txMetaFeeAmount] = val
	}
	if len(meta) == 0 {
		return nil
	}
	return meta
}
