package mynalog

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"net/http"
	"time"
)

// tokenLifetime is the assumed access-token lifetime when the service does not say. The real one is
// about an hour; a short assumption only costs an extra refresh.
const tokenLifetime = time.Hour

// tokenSlack is subtracted from the expiry so a token is renewed slightly early rather than failing
// mid-run and forcing a probe.
const tokenSlack = time.Minute

// deviceIDLength is how many hex characters of the login digest form the device id. It matches what
// the web cabinet issues, and the length is what the service accepts.
const deviceIDLength = 21

// Session is one authenticated cabinet session. Token is the short-lived bearer credential;
// RefreshToken is the long-lived one that renews it and is the only part worth persisting. INN
// identifies the taxpayer and is needed to build a receipt link.
type Session struct {
	Token        string
	RefreshToken string
	INN          string
	ExpiresAt    time.Time
}

// Valid reports whether the session still has a usable access token at now.
func (s Session) Valid(now time.Time) bool {
	return s.Token != "" && now.Before(s.ExpiresAt)
}

// DeviceIDForLogin derives a stable device identifier from the taxpayer's login. It is
// deterministic so that re-authenticating after a restart presents the same "device" instead of
// looking like a new one every time.
func DeviceIDForLogin(login string) string {
	sum := sha256.Sum256([]byte(login))
	return hex.EncodeToString(sum[:])[:deviceIDLength]
}

// authResponse is the shape both authentication endpoints answer with.
type authResponse struct {
	Token         string `json:"token"`
	RefreshToken  string `json:"refreshToken"`
	TokenExpireIn string `json:"tokenExpireIn"`
	Profile       struct {
		INN string `json:"inn"`
	} `json:"profile"`
}

// session converts an authentication answer into a Session, defaulting the expiry when the service
// did not state one and falling back to the login for the taxpayer id.
func (a authResponse) session(login string, now time.Time) (Session, error) {
	if a.Token == "" {
		return Session{}, fmt.Errorf("mynalog: authentication returned no token: %w", ErrPermanent)
	}
	expires := now.Add(tokenLifetime)
	if a.TokenExpireIn != "" {
		if t, err := time.Parse(time.RFC3339, a.TokenExpireIn); err == nil {
			expires = t
		}
	}
	inn := a.Profile.INN
	if inn == "" {
		inn = login
	}
	return Session{
		Token:        a.Token,
		RefreshToken: a.RefreshToken,
		INN:          inn,
		ExpiresAt:    expires.Add(-tokenSlack),
	}, nil
}

// AuthByPassword exchanges the taxpayer's cabinet login (their INN) and password for a session.
// Callers persist only the returned RefreshToken: the password is never stored, so this runs once,
// interactively, and the session renews itself from then on.
func (c *Client) AuthByPassword(ctx context.Context, login, password string, now time.Time) (Session, error) {
	body := struct {
		Username   string     `json:"username"`
		Password   string     `json:"password"`
		DeviceInfo deviceInfo `json:"deviceInfo"`
	}{Username: login, Password: password, DeviceInfo: c.device()}

	var out authResponse
	if err := c.do(ctx, http.MethodPost, "/api/v1/auth/lkfl", "", body, &out); err != nil {
		// A password login rejected with 401 is bad credentials, not an aged-out token: reporting it
		// as ErrAuthExpired would send the caller into a pointless refresh loop.
		if errors.Is(err, ErrAuthExpired) {
			return Session{}, fmt.Errorf("mynalog: login rejected: %w", ErrPermanent)
		}
		return Session{}, err
	}
	return out.session(login, now)
}

// AuthByRefresh renews a session from a stored refresh token. The service sometimes rotates the
// token, so the caller must persist the returned RefreshToken whenever it differs from the one
// supplied — otherwise the next renewal presents a token the service has already retired.
func (c *Client) AuthByRefresh(ctx context.Context, refreshToken, login string, now time.Time) (Session, error) {
	body := struct {
		DeviceInfo   deviceInfo `json:"deviceInfo"`
		RefreshToken string     `json:"refreshToken"`
	}{DeviceInfo: c.device(), RefreshToken: refreshToken}

	var out authResponse
	if err := c.do(ctx, http.MethodPost, "/api/v1/auth/token", "", body, &out); err != nil {
		// The stored token is dead; only a fresh password login can recover, so this is permanent
		// for the caller rather than something to retry.
		if errors.Is(err, ErrAuthExpired) {
			return Session{}, fmt.Errorf("mynalog: refresh token rejected: %w", ErrPermanent)
		}
		return Session{}, err
	}
	sess, err := out.session(login, now)
	if err != nil {
		return Session{}, err
	}
	if sess.RefreshToken == "" {
		sess.RefreshToken = refreshToken
	}
	return sess, nil
}
