// Package mynalog talks to the personal cabinet of the Russian professional-income tax regime
// (НПД) at lknpd.nalog.ru: it registers an income and receives the receipt uuid the tax service
// assigns, annuls that receipt when the money is returned, and searches the taxpayer's own income
// list. It is pure provider glue — no database, no payments-domain coupling — so the payments
// domain stays unaware of it and this layer is unit-testable in isolation.
//
// Three properties of this API shape everything here.
//
// First, it is NOT official. There is no published contract and no sandbox: the endpoints below
// were established by observing the web cabinet, and every call lands on the operator's real tax
// account. The format can change without warning, so errors are classified rather than merely
// logged, and the caller is expected to have a manual fallback.
//
// Second, registering an income is NOT idempotent — the API accepts no idempotency key. A timeout
// therefore does not mean "nothing happened": the receipt may well exist. The only recovery is
// FindIncome, which searches the taxpayer's own income list for the exact service name we sent,
// which is why that name must carry a unique marker (see IncomeName).
//
// Third, the access token lives about an hour while the refresh token is long-lived, so callers
// persist only the refresh token and mint an access token per session.
package mynalog

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

// DefaultBaseURL is the production cabinet root. NewClient's baseURL overrides it (tests point it
// at an httptest server).
const DefaultBaseURL = "https://lknpd.nalog.ru"

// RequestTimeout bounds a single API call. It is deliberately generous: the tax service is slow
// under load, and a premature client-side timeout is expensive here — an ambiguous outcome halts
// the export queue until a human resolves it, so waiting is far cheaper than guessing. It is
// exported so a caller sharing one HTTP client across calls applies the same ceiling.
const RequestTimeout = 60 * time.Second

// maxResponseBytes caps how much of a response body is read, so a misbehaving endpoint cannot
// exhaust memory.
const maxResponseBytes = 1 << 20

// Error classes every failure is sorted into. They exist so the caller can act differently on each
// without parsing messages: a silent re-authentication, a backoff, a retry next run, or a hard stop
// with an operator alert.
var (
	// ErrAuthExpired is a rejected token. It is routine — the access token simply aged out — and
	// the caller re-authenticates instead of alerting.
	ErrAuthExpired = errors.New("mynalog: authentication expired")
	// ErrThrottled is the service asking us to slow down. The run stops and resumes later; it is
	// worth an alert only if it persists.
	ErrThrottled = errors.New("mynalog: throttled")
	// ErrTransient is a server-side fault, a timeout or a broken connection: the request may or may
	// not have been applied, and repeating it later is reasonable.
	ErrTransient = errors.New("mynalog: service unavailable")
	// ErrPermanent is a rejection that repeating cannot fix — a malformed request, or the API
	// having changed shape underneath us. It stops the run and alerts the operator.
	ErrPermanent = errors.New("mynalog: request rejected")
	// ErrIncomeLimit is the annual professional-income ceiling being exceeded. It means the tax
	// regime itself no longer applies and the operator must act rather than debug, so it is
	// separated for the sake of the alert wording — but it deliberately WRAPS ErrPermanent, so a
	// caller that only asks errors.Is(err, ErrPermanent) still stops. Losing that subsumption would
	// let the one fault the operator most needs to hear about slip through as a retryable one.
	ErrIncomeLimit = fmt.Errorf("%w: annual income limit exceeded", ErrPermanent)
)

// APIError is a non-2xx answer. Message is the service's own explanation when the body carried
// one; Code is its error code where present. Class is the sentinel this fault was sorted into and
// is what errors.Is matches on.
type APIError struct {
	StatusCode int
	Code       string
	Message    string
	class      error
}

// Error renders the status together with the service's own message. It never includes credentials:
// the admin console prints err.Error() verbatim to the operator.
func (e *APIError) Error() string {
	msg := e.Message
	if msg == "" {
		msg = e.Code
	}
	if msg == "" {
		return fmt.Sprintf("mynalog: http %d", e.StatusCode)
	}
	return fmt.Sprintf("mynalog: http %d: %s", e.StatusCode, msg)
}

// Unwrap exposes the error class so errors.Is(err, ErrPermanent) and friends work.
func (e *APIError) Unwrap() error { return e.class }

// classify sorts an HTTP status and the service's own message into one of the error classes.
//
// The income-limit test is a heuristic over the code and message, because the undocumented API
// gives no stable machine-readable marker for it. Missing it is harmless: the fault stays an
// ErrPermanent, so the run still stops and the operator is still alerted — only the wording of the
// alert is less specific.
func classify(status int, code, message string) error {
	switch {
	case status == http.StatusUnauthorized:
		return ErrAuthExpired
	case status == http.StatusTooManyRequests:
		return ErrThrottled
	case status >= 500:
		return ErrTransient
	case status >= 400:
		if mentionsIncomeLimit(code) || mentionsIncomeLimit(message) {
			return ErrIncomeLimit
		}
		return ErrPermanent
	}
	return nil
}

// mentionsIncomeLimit reports whether s reads like the annual-ceiling rejection, in either of the
// two languages the service answers in.
func mentionsIncomeLimit(s string) bool {
	s = strings.ToLower(s)
	return strings.Contains(s, "лимит") || strings.Contains(s, "limit")
}

// Client calls one taxpayer's cabinet. deviceID pins every call to a single synthetic "device", so
// repeated logins look like the same browser rather than a new one each time.
type Client struct {
	baseURL  string
	deviceID string
	http     *http.Client
}

// NewClient builds a client against baseURL (empty means DefaultBaseURL) identifying itself as
// deviceID. hc may be nil, in which case a client carrying only the ceiling timeout is used;
// per-request cancellation always rides the context.
func NewClient(baseURL, deviceID string, hc *http.Client) *Client {
	if baseURL == "" {
		baseURL = DefaultBaseURL
	}
	if hc == nil {
		hc = &http.Client{Timeout: RequestTimeout}
	}
	return &Client{baseURL: strings.TrimSuffix(baseURL, "/"), deviceID: deviceID, http: hc}
}

// DeviceID returns the device identifier this client presents, so callers can persist it alongside
// the refresh token the service issued for it.
func (c *Client) DeviceID() string { return c.deviceID }

// deviceInfo is the device descriptor every authentication call carries.
type deviceInfo struct {
	SourceDeviceID string            `json:"sourceDeviceId"`
	SourceType     string            `json:"sourceType"`
	AppVersion     string            `json:"appVersion"`
	MetaDetails    map[string]string `json:"metaDetails"`
}

// userAgent is sent both as the HTTP header and inside deviceInfo, which is what the web cabinet
// does; the service correlates the two.
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " +
	"Chrome/142.0.0.0 Safari/537.36"

// device describes this client to the authentication endpoints.
func (c *Client) device() deviceInfo {
	return deviceInfo{
		SourceDeviceID: c.deviceID,
		SourceType:     "WEB",
		AppVersion:     "1.0.0",
		MetaDetails:    map[string]string{"userAgent": userAgent},
	}
}

// errorBody is the shape of the service's error responses. Not every fault produces one.
type errorBody struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

// do performs one call and decodes the result into out, which may be nil when the body is not
// needed. token, when non-empty, is sent as the bearer credential. Every failure comes back
// wrapping one of the error classes.
func (c *Client) do(ctx context.Context, method, path, token string, body, out any) error {
	var payload io.Reader
	if body != nil {
		buf, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("mynalog: encode %s %s: %w", method, path, err)
		}
		payload = bytes.NewReader(buf)
	}
	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, payload)
	if err != nil {
		return fmt.Errorf("mynalog: build %s %s: %w", method, path, err)
	}
	req.Header.Set("Accept", "application/json, text/plain, */*")
	req.Header.Set("Referer", c.baseURL+"/")
	req.Header.Set("User-Agent", userAgent)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		// A refused connection, a reset or a timeout: we cannot tell whether the request was
		// applied, so it is transient by definition and the caller must probe before retrying.
		return fmt.Errorf("mynalog: %s %s: %w: %v", method, path, ErrTransient, err)
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
	if err != nil {
		return fmt.Errorf("mynalog: read %s %s: %w: %v", method, path, ErrTransient, err)
	}
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		var eb errorBody
		// The body is the service's error object on a handled fault and something else entirely on
		// an edge failure; either way the status alone is enough to classify.
		_ = json.Unmarshal(raw, &eb)
		return &APIError{
			StatusCode: resp.StatusCode,
			Code:       eb.Code,
			Message:    eb.Message,
			class:      classify(resp.StatusCode, eb.Code, eb.Message),
		}
	}
	if out == nil {
		return nil
	}
	if err := json.Unmarshal(raw, out); err != nil {
		// A 200 we cannot parse means the contract moved under us: repeating will not help.
		return fmt.Errorf("mynalog: decode %s %s: %w: %v", method, path, ErrPermanent, err)
	}
	return nil
}
