// Package websms is a small Connexus API client for OTP / 2FA flows.
//
// It handles:
//   - OAuth-style client credentials -> bearer token exchange
//   - Token cache + auto-refresh before expiry
//   - Sending an OTP (auto-generated 6-digit OR your own 4-8 digit code)
//   - Server-side OTP verification with TTL + attempt tracking
//
// No third-party deps - net/http + std library only.
package websms

import (
	"crypto/subtle"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"
)

const (
	BaseURL      = "https://websms.co.nz/api/connexus"
	refreshSkew  = 5 * time.Minute
	httpTimeout  = 10 * time.Second
	maxAttempts  = 5
)

// Client is safe for concurrent use.
type Client struct {
	clientID, clientSecret string
	http                   *http.Client

	mu        sync.Mutex
	token     string
	tokenExp  time.Time
	otpStore  map[string]*otpRecord
}

type otpRecord struct {
	code     string
	expires  time.Time
	attempts int
}

type tokenResp struct {
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	ExpiresIn   int    `json:"expires_in"`
}

// OTPResponse mirrors the /sms/otp success body.
type OTPResponse struct {
	Status    string `json:"status"`
	MessageID string `json:"message_id"`
	To        string `json:"to"`
	From      string `json:"from"`
	Code      string `json:"code"`
	Parts     int    `json:"parts"`
}

func New(clientID, clientSecret string) *Client {
	return &Client{
		clientID:     clientID,
		clientSecret: clientSecret,
		http:         &http.Client{Timeout: httpTimeout},
		otpStore:     make(map[string]*otpRecord),
	}
}

// AccessToken returns a valid bearer token, refreshing transparently.
func (c *Client) AccessToken() (string, error) {
	c.mu.Lock()
	if c.token != "" && time.Until(c.tokenExp) > refreshSkew {
		t := c.token
		c.mu.Unlock()
		return t, nil
	}
	c.mu.Unlock()
	return c.RefreshToken()
}

// RefreshToken forces a fresh token regardless of cache state.
func (c *Client) RefreshToken() (string, error) {
	body := url.Values{
		"client_id":     {c.clientID},
		"client_secret": {c.clientSecret},
	}
	var t tokenResp
	if err := c.post("/auth/token", body, false, &t); err != nil {
		return "", fmt.Errorf("refresh token: %w", err)
	}
	if t.AccessToken == "" {
		return "", errors.New("token endpoint did not return access_token")
	}
	exp := t.ExpiresIn
	if exp == 0 {
		exp = 86400
	}
	c.mu.Lock()
	c.token = t.AccessToken
	c.tokenExp = time.Now().Add(time.Duration(exp) * time.Second)
	c.mu.Unlock()
	return t.AccessToken, nil
}

// SendOTP sends a verification code. If code == "", WebSMS auto-generates one
// and returns it; either way the issued code is stored locally for VerifyOTP().
//
// webOtpDomain is optional - pass a bare hostname (e.g. "app.example.com") to
// have WebSMS append the WebOTP autofill marker ("@host #code") as the SMS's
// last line so Chrome/Safari can offer one-tap autofill on your origin.
func (c *Client) SendOTP(to, company, code, comment, webOtpDomain string, ttl time.Duration) (*OTPResponse, error) {
	if ttl <= 0 {
		ttl = 5 * time.Minute
	}
	body := url.Values{
		"to":         {to},
		"msgCompany": {company},
	}
	if code != "" {
		body.Set("msgCode", code)
	}
	if comment != "" {
		body.Set("msgComment", comment)
	}
	if webOtpDomain != "" {
		body.Set("webOtpDomain", webOtpDomain)
	}

	var resp OTPResponse
	if err := c.post("/sms/otp", body, true, &resp); err != nil {
		return nil, err
	}
	if resp.Status != "success" || resp.Code == "" {
		return nil, fmt.Errorf("otp send failed: %+v", resp)
	}

	c.mu.Lock()
	c.otpStore[to] = &otpRecord{
		code:    resp.Code,
		expires: time.Now().Add(ttl),
	}
	c.mu.Unlock()
	return &resp, nil
}

// VerifyOTP is single-shot - the record is consumed on success. Caps at 5 attempts.
func (c *Client) VerifyOTP(to, userInput string) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	rec, ok := c.otpStore[to]
	if !ok {
		return false
	}
	if time.Now().After(rec.expires) || rec.attempts >= maxAttempts {
		delete(c.otpStore, to)
		return false
	}
	rec.attempts++
	if subtle.ConstantTimeCompare([]byte(rec.code), []byte(userInput)) == 1 {
		delete(c.otpStore, to)
		return true
	}
	return false
}

func (c *Client) post(path string, body url.Values, authenticated bool, out interface{}) error {
	req, err := http.NewRequest("POST", BaseURL+path, strings.NewReader(body.Encode()))
	if err != nil {
		return err
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	if authenticated {
		token, err := c.AccessToken()
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+token)
	}

	res, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	raw, _ := io.ReadAll(res.Body)

	// Token revoked early? Refresh once and retry.
	if res.StatusCode == http.StatusUnauthorized && authenticated {
		c.mu.Lock()
		c.token = ""
		c.mu.Unlock()
		if _, err := c.RefreshToken(); err != nil {
			return err
		}
		return c.post(path, body, true, out)
	}

	if err := json.Unmarshal(raw, out); err != nil {
		return fmt.Errorf("non-JSON response from %s (%d): %s", path, res.StatusCode, raw)
	}
	return nil
}
