"""WebSMS Connexus API client for OTP / 2FA flows.

Handles:
  - OAuth-style client credentials -> bearer token exchange
  - In-process token cache with 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

Python 3.8+, requires `requests`.

    from websms_client import WebSMSClient
    c = WebSMSClient(os.environ['WEBSMS_CLIENT_ID'], os.environ['WEBSMS_CLIENT_SECRET'])
    c.send_otp('6421234567', 'MyApp')
    c.verify_otp('6421234567', user_input)
"""

from __future__ import annotations

import hmac
import threading
import time
from typing import Dict, Optional

import requests

BASE_URL = 'https://websms.co.nz/api/connexus'
REFRESH_SKEW = 300  # refresh 5 min before actual expiry


class WebSMSClient:
    def __init__(self, client_id: str, client_secret: str) -> None:
        if not client_id or not client_secret:
            raise ValueError('client_id and client_secret are required')
        self._client_id = client_id
        self._client_secret = client_secret
        self._token: Optional[Dict] = None  # {'access_token', 'expires_at'}
        self._otp_store: Dict[str, Dict] = {}  # to -> {code, expires_at, attempts}
        self._lock = threading.Lock()

    # --- Auth ----------------------------------------------------------------

    def get_access_token(self) -> str:
        with self._lock:
            if self._token and self._token['expires_at'] > time.time() + REFRESH_SKEW:
                return self._token['access_token']
        return self.refresh_token()

    def refresh_token(self) -> str:
        data = self._post('/auth/token', {
            'client_id': self._client_id,
            'client_secret': self._client_secret,
        }, authenticated=False)

        if not data.get('access_token'):
            raise RuntimeError(f'Token endpoint did not return access_token: {data}')

        with self._lock:
            self._token = {
                'access_token': data['access_token'],
                'expires_at': time.time() + int(data.get('expires_in', 86400)),
            }
        return data['access_token']

    # --- OTP -----------------------------------------------------------------

    def send_otp(
        self,
        to: str,
        company: str,
        code: Optional[str] = None,
        comment: Optional[str] = None,
        ttl_seconds: int = 300,
        web_otp_domain: Optional[str] = None,
    ) -> Dict:
        """Send an OTP. If `code` is None, WebSMS auto-generates one and returns it.
        Either way the issued code is stored locally for verify_otp() to compare.

        Pass `web_otp_domain` (a bare hostname like '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.
        """
        params = {'to': to, 'msgCompany': company}
        if code is not None:
            params['msgCode'] = str(code)
        if comment is not None:
            params['msgComment'] = comment
        if web_otp_domain is not None:
            params['webOtpDomain'] = web_otp_domain

        resp = self._post('/sms/otp', params, authenticated=True)
        if resp.get('status') != 'success' or not resp.get('code'):
            raise RuntimeError(f'OTP send failed: {resp}')

        with self._lock:
            self._otp_store[to] = {
                'code': str(resp['code']),
                'expires_at': time.time() + ttl_seconds,
                'attempts': 0,
            }
        return resp

    def verify_otp(self, to: str, user_input: str) -> bool:
        """Single-shot verify - the record is consumed on success.
        Caps at 5 attempts to prevent brute force."""
        with self._lock:
            rec = self._otp_store.get(to)
            if not rec:
                return False
            if rec['expires_at'] < time.time() or rec['attempts'] >= 5:
                self._otp_store.pop(to, None)
                return False

            rec['attempts'] += 1
            if hmac.compare_digest(rec['code'], str(user_input)):
                self._otp_store.pop(to, None)
                return True
            return False

    # --- HTTP ----------------------------------------------------------------

    def _post(self, path: str, params: Dict, authenticated: bool) -> Dict:
        headers = {'Accept': 'application/json'}
        if authenticated:
            headers['Authorization'] = 'Bearer ' + self.get_access_token()

        r = requests.post(BASE_URL + path, data=params, headers=headers, timeout=10)

        # Token revoked early? Refresh once and retry.
        if r.status_code == 401 and authenticated:
            with self._lock:
                self._token = None
            self.refresh_token()
            return self._post(path, params, authenticated=True)

        try:
            return r.json()
        except ValueError as e:
            raise RuntimeError(f'Non-JSON response from {path} ({r.status_code}): {r.text}') from e
