// Package lightning — Alby Hub node implementation.
//
// Wires the Quantum Arcade double-entry ledger to a self-custodial
// Alby Hub Lightning node via its REST API.
//
// Alby Hub uses satoshis; the internal ledger uses millisatoshis.
// All conversions happen at this boundary.
package lightning

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

// AlbyNode implements Node against an Alby Hub instance.
type AlbyNode struct {
	baseURL string
	token   string
	client  *http.Client
}

// NewAlbyNode returns a Node backed by the Alby Hub at baseURL.
// token is the full-access JWT.
func NewAlbyNode(baseURL, token string) *AlbyNode {
	return &AlbyNode{
		baseURL: baseURL,
		token:   token,
		client: &http.Client{
			Timeout: 30 * time.Second,
		},
	}
}

// ───────── request helpers ─────────

func (a *AlbyNode) do(ctx context.Context, method, path string, body any) ([]byte, error) {
	var r io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return nil, err
		}
		r = bytes.NewReader(b)
	}
	req, err := http.NewRequestWithContext(ctx, method, a.baseURL+"/api/"+path, r)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+a.token)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := a.client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("alby request: %w", err)
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("alby %d: %s", resp.StatusCode, string(data[:min(len(data), 200)]))
	}
	return data, nil
}

func (a *AlbyNode) get(ctx context.Context, path string) ([]byte, error) {
	return a.do(ctx, "GET", path, nil)
}

func (a *AlbyNode) post(ctx context.Context, path string, body any) ([]byte, error) {
	return a.do(ctx, "POST", path, body)
}

// ───────── Node interface ─────────

type albyInvoice struct {
	PaymentHash string `json:"paymentHash"`
	Invoice     string `json:"invoice"`
	Amount      int64  `json:"amount"` // sats
	State       string `json:"state"`
	ExpiresAt   string `json:"expiresAt"`
}

// CreateInvoice creates a Lightning invoice via Alby Hub.
//
// Alby Hub speaks satoshis. Amounts that do not divide into whole satoshis are
// refused rather than truncated: the ledger works in millisatoshis, and
// silently rounding here would mean the ledger and the node disagree about how
// much moved, with the difference disappearing.
func (a *AlbyNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
	sats, err := exactSats(amountMsat)
	if err != nil {
		return Invoice{}, err
	}
	raw, err := a.post(ctx, "invoices", map[string]any{
		"amount":      sats,
		"description": memo,
	})
	if err != nil {
		return Invoice{}, err
	}
	var inv albyInvoice
	if err := json.Unmarshal(raw, &inv); err != nil {
		return Invoice{}, fmt.Errorf("parsing alby invoice: %w", err)
	}
	expires, _ := time.Parse(time.RFC3339, inv.ExpiresAt)
	return Invoice{
		PaymentHash: inv.PaymentHash,
		Bolt11:      inv.Invoice,
		AmountMsat:  satToMsat(inv.Amount),
		ExpiresAt:   expires,
	}, nil
}

// LookupInvoice checks whether an invoice has been paid.
func (a *AlbyNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
	raw, err := a.get(ctx, "invoices/"+paymentHash)
	if err != nil {
		return false, 0, err
	}
	var inv albyInvoice
	if err := json.Unmarshal(raw, &inv); err != nil {
		return false, 0, fmt.Errorf("parsing alby invoice: %w", err)
	}
	return inv.State == "settled", satToMsat(inv.Amount), nil
}

type albyPayment struct {
	PaymentHash string `json:"paymentHash"`
	Preimage    string `json:"preimage"`
	Amount      int64  `json:"amountSat"`
	Fee         int64  `json:"feesPaidSat"`
	State       string `json:"state"`
}

// PayInvoice sends an outbound Lightning payment.
//
// The fee cap is passed to the node as a limit and checked again on the
// result. Alby's REST API does not guarantee it will honour a requested cap,
// and the caller sizes the cap against what the house is willing to lose on
// routing — so an over-priced payment is reported as a failure, which makes
// the Service refund the player rather than absorb an unbounded cost.
func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
	maxFeeSats := maxFeeMsat / 1000

	raw, err := a.post(ctx, "payments", map[string]any{
		"invoice": bolt11,
		// Requested limit. Not all versions enforce it, hence the check below.
		"maxFeeSat": maxFeeSats,
	})
	if err != nil {
		return Payment{}, err
	}
	var p albyPayment
	if err := json.Unmarshal(raw, &p); err != nil {
		return Payment{}, fmt.Errorf("parsing alby payment: %w", err)
	}
	if p.State != "settled" {
		return Payment{}, fmt.Errorf("%w: payment %s state=%s",
			ErrPaymentFailed, p.PaymentHash, p.State)
	}

	feeMsat := satToMsat(p.Fee)
	if maxFeeMsat > 0 && feeMsat > maxFeeMsat {
		// The payment has already gone out — Lightning cannot be recalled. Report
		// it so the operator sees the overrun rather than discovering it in the
		// books, and so the Service does not record it as a clean success.
		return Payment{}, fmt.Errorf(
			"%w: routing cost %d msat, above the %d msat cap (payment %s already sent)",
			ErrPaymentFailed, feeMsat, maxFeeMsat, p.PaymentHash)
	}

	return Payment{
		PaymentHash: p.PaymentHash,
		Preimage:    p.Preimage,
		AmountMsat:  satToMsat(p.Amount),
		FeeMsat:     satToMsat(p.Fee),
	}, nil
}

type albyBalances struct {
	Lightning struct {
		TotalSpendable int64 `json:"totalSpendableSat"`
	} `json:"lightning"`
}

// Balance returns the spendable Lightning balance in millisatoshis.
func (a *AlbyNode) Balance(ctx context.Context) (int64, error) {
	raw, err := a.get(ctx, "balances")
	if err != nil {
		return 0, err
	}
	var b albyBalances
	if err := json.Unmarshal(raw, &b); err != nil {
		return 0, fmt.Errorf("parsing alby balances: %w", err)
	}
	return satToMsat(b.Lightning.TotalSpendable), nil
}

// ───────── sat ↔ msat conversion ─────────

func satToMsat(sats int64) int64 { return sats * 1000 }

// exactSats converts millisatoshis to satoshis, refusing any amount that would
// lose precision.
//
// Lightning cannot carry sub-satoshi amounts. Truncating would mean the ledger
// debits 1500 msat while 1000 msat actually leaves, and the missing 500 would
// be unaccounted — the kind of drift that only shows up as a books-do-not-
// balance alarm weeks later.
func exactSats(msat int64) (int64, error) {
	if msat <= 0 {
		return 0, fmt.Errorf("%w: amount %d is not positive", ErrAmountOutOfRange, msat)
	}
	if msat%1000 != 0 {
		return 0, fmt.Errorf(
			"%w: %d msat is not a whole number of satoshis; Lightning cannot send sub-satoshi amounts",
			ErrAmountOutOfRange, msat)
	}
	return msat / 1000, nil
}
