package mynalog

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"regexp"
	"time"
)

// timeLayout is how the service wants a moment: ISO-8601 to the second with an explicit numeric
// offset. The offset is load-bearing — it decides which tax month a near-midnight payment falls
// into — so the layout never renders UTC as "Z".
const timeLayout = "2006-01-02T15:04:05-07:00"

// queryTimeLayout is the same with milliseconds, which is what the income-list filter expects.
const queryTimeLayout = "2006-01-02T15:04:05.000-07:00"

// findLimit is how many recent incomes the probe reads in one page. The probe searches a one-day
// window for a name carrying a unique order marker, so a single page is ample; a larger window
// would need paging.
const findLimit = 50

// cancelReasonRefund is the annulment reason the tax service recognises for money given back.
const cancelReasonRefund = "Возврат средств"

// decimalPattern accepts exactly the shape a JSON number may take here: an integer part with an
// optional fractional part, no exponent and no sign.
var decimalPattern = regexp.MustCompile(`^\d+(\.\d+)?$`)

// decimal is a monetary amount that marshals as a JSON *number* while being carried as an exact
// decimal string. payments.Money.Major() already renders that string, and passing it through
// untouched is what keeps binary floating point out of a tax filing.
type decimal string

// MarshalJSON emits the amount verbatim as a JSON number, refusing anything that is not a plain
// decimal literal rather than silently producing invalid JSON.
func (d decimal) MarshalJSON() ([]byte, error) {
	if !decimalPattern.MatchString(string(d)) {
		return nil, fmt.Errorf("mynalog: %q is not a decimal amount: %w", string(d), ErrPermanent)
	}
	return []byte(d), nil
}

// service is one line of a receipt. Quantity is always 1: an income is registered as a single
// service priced at the whole amount, not as a quantity of units.
type service struct {
	Name     string  `json:"name"`
	Amount   decimal `json:"amount"`
	Quantity int     `json:"quantity"`
}

// client identifies the payer. For a private individual every field stays null and the type alone
// selects the 4% rate; a company or sole trader would instead need its INN and name here, which is
// why this rail is restricted to individual buyers.
type client struct {
	ContactPhone *string `json:"contactPhone"`
	DisplayName  *string `json:"displayName"`
	INN          *string `json:"inn"`
	IncomeType   string  `json:"incomeType"`
}

// incomeTypeIndividual marks the payer as a private individual.
const incomeTypeIndividual = "FROM_INDIVIDUAL"

// paymentTypeCash is the settlement type sent for every income. It does not affect the tax owed;
// it is what every known client of this API sends.
const paymentTypeCash = "CASH"

// IncomeRequest is one income to register. Name is the service description shown to the tax service
// and to the buyer, and doubles as the only key by which FindIncome can locate the receipt
// afterwards. Amount is an exact decimal string. OperationTime is when the money was actually
// received — not when we got round to reporting it — and must already be in the taxpayer's time
// zone, because its offset decides the tax period.
type IncomeRequest struct {
	Name          string
	Amount        string
	OperationTime time.Time
	RequestTime   time.Time
}

// incomeBody is the wire shape of a registration call.
type incomeBody struct {
	OperationTime string    `json:"operationTime"`
	RequestTime   string    `json:"requestTime"`
	Services      []service `json:"services"`
	TotalAmount   string    `json:"totalAmount"`
	Client        client    `json:"client"`
	PaymentType   string    `json:"paymentType"`
	// IgnoreMaxTotalIncomeRestriction stays false so that exceeding the annual professional-income
	// ceiling is refused loudly instead of being quietly filed.
	IgnoreMaxTotalIncomeRestriction bool `json:"ignoreMaxTotalIncomeRestriction"`
}

// incomeResponse carries the receipt identifier the tax service assigns. That uuid is the only
// handle on the receipt afterwards: without it the income can never be annulled.
type incomeResponse struct {
	ApprovedReceiptUUID string `json:"approvedReceiptUuid"`
}

// AddIncome registers one income and returns the receipt uuid the tax service assigned.
//
// This call is NOT idempotent — the API takes no idempotency key — so a returned error does not
// mean nothing was recorded. On anything other than a clean success the caller must probe with
// FindIncome before considering a retry, or it risks filing the same income twice.
func (c *Client) AddIncome(ctx context.Context, sess Session, req IncomeRequest) (string, error) {
	body := incomeBody{
		OperationTime: req.OperationTime.Format(timeLayout),
		RequestTime:   req.RequestTime.Format(timeLayout),
		Services:      []service{{Name: req.Name, Amount: decimal(req.Amount), Quantity: 1}},
		TotalAmount:   req.Amount,
		Client:        client{IncomeType: incomeTypeIndividual},
		PaymentType:   paymentTypeCash,
	}
	var out incomeResponse
	if err := c.do(ctx, http.MethodPost, "/api/v1/income", sess.Token, body, &out); err != nil {
		return "", err
	}
	if out.ApprovedReceiptUUID == "" {
		// A success with no receipt id leaves us unable to annul the income later, so it counts as
		// an ambiguous outcome and the caller must probe rather than assume either way.
		return "", fmt.Errorf("mynalog: income accepted without a receipt id: %w", ErrTransient)
	}
	return out.ApprovedReceiptUUID, nil
}

// cancelBody is the wire shape of an annulment call.
type cancelBody struct {
	OperationTime string `json:"operationTime"`
	RequestTime   string `json:"requestTime"`
	Comment       string `json:"comment"`
	ReceiptUUID   string `json:"receiptUuid"`
}

// CancelIncome annuls a previously registered receipt because the money was returned. The tax
// service has no separate refund concept: annulling the receipt is the refund, and it removes the
// income from the tax base. now must already be in the taxpayer's time zone.
func (c *Client) CancelIncome(ctx context.Context, sess Session, receiptUUID string, now time.Time) error {
	stamp := now.Format(timeLayout)
	body := cancelBody{
		OperationTime: stamp,
		RequestTime:   stamp,
		Comment:       cancelReasonRefund,
		ReceiptUUID:   receiptUUID,
	}
	return c.do(ctx, http.MethodPost, "/api/v1/cancel", sess.Token, body, nil)
}

// incomeListItem is one entry of the taxpayer's income list. A non-empty CancellationInfo marks an
// already-annulled receipt, which must never be matched: annulling it again would fail, and
// treating it as ours would hide a genuinely missing income.
type incomeListItem struct {
	Name                string          `json:"name"`
	ApprovedReceiptUUID string          `json:"approvedReceiptUuid"`
	CancellationInfo    json.RawMessage `json:"cancellationInfo"`
}

// incomeListResponse is the paged income list.
type incomeListResponse struct {
	Content []incomeListItem `json:"content"`
}

// FindIncome searches the taxpayer's own income list between from and to for a live receipt whose
// service name is exactly name, and reports its uuid.
//
// This is the recovery path for the non-idempotent AddIncome: after a timeout it answers the only
// question that matters — was the receipt created after all? It is exact only because the name
// carries a unique order marker; searching by amount and description alone would confuse two
// identical purchases. from and to must already be in the taxpayer's time zone.
func (c *Client) FindIncome(ctx context.Context, sess Session, name string, from, to time.Time) (string, bool, error) {
	q := url.Values{}
	q.Set("from", from.Format(queryTimeLayout))
	q.Set("to", to.Format(queryTimeLayout))
	q.Set("offset", "0")
	q.Set("sortBy", "operation_time:desc")
	q.Set("limit", fmt.Sprint(findLimit))

	var out incomeListResponse
	if err := c.do(ctx, http.MethodGet, "/api/v1/incomes?"+q.Encode(), sess.Token, nil, &out); err != nil {
		return "", false, err
	}
	for _, item := range out.Content {
		if item.Name != name || item.ApprovedReceiptUUID == "" {
			continue
		}
		if len(item.CancellationInfo) > 0 && string(item.CancellationInfo) != "null" {
			continue
		}
		return item.ApprovedReceiptUUID, true, nil
	}
	return "", false, nil
}
