<?php
/**
 * HTTP client for the TextTango v2 API.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Api;

use TextTango\Support\Settings;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Thin client over the WordPress HTTP API (spec §3, §12).
 *
 * Deliberately not `final`: tests override {@see self::sleep_seconds()} so backoff
 * does not actually block a test run.
 *
 * Retry policy is asymmetric, and that asymmetry is the whole point. GET requests
 * are safe to repeat, so they may back off and retry. **POST sends are never
 * retried here.** The API exposes no idempotency key (spec §2), so a retry after a
 * timeout could deliver — and bill — a second SMS. Rescheduling a failed send is
 * the queue's job, where the dedupe row proves whether anything was actually sent.
 */
class Client {

	/**
	 * Request timeout in seconds (spec §12 calls for 10–15s).
	 */
	public const TIMEOUT = 15;

	/**
	 * Never sleep longer than this between retries, however large `retry_after` is.
	 */
	private const MAX_BACKOFF_SECONDS = 8;

	/**
	 * API base URL, without a trailing slash.
	 */
	private string $base_url;

	/**
	 * Bearer token.
	 */
	private string $token;

	/**
	 * Request timeout in seconds.
	 */
	private int $timeout;

	/**
	 * Constructor.
	 *
	 * @param string $base_url API base URL, e.g. https://app.texttango.com/api.
	 * @param string $token    Bearer token.
	 * @param int    $timeout  Request timeout in seconds.
	 */
	public function __construct( string $base_url, string $token, int $timeout = self::TIMEOUT ) {
		$this->base_url = untrailingslashit( trim( $base_url ) );
		$this->token    = trim( $token );
		$this->timeout  = $timeout;
	}

	/**
	 * Build a client from stored settings.
	 *
	 * @return Client|WP_Error Error when the site is not connected yet.
	 */
	public static function from_settings() {
		$token = Settings::get_api_key();

		if ( '' === $token ) {
			return new WP_Error(
				'texttango_not_connected',
				__( 'No TextTango API token is configured. Add one under TextTango → Settings.', 'texttango' )
			);
		}

		$base_url = (string) Settings::get( 'api_base_url' );
		$valid    = self::validate_base_url( $base_url );

		if ( is_wp_error( $valid ) ) {
			return $valid;
		}

		return new self( $base_url, $token );
	}

	/**
	 * Validate an API base URL.
	 *
	 * HTTPS is required (spec §13), relaxed only for local development hosts so a
	 * developer can point the plugin at their own instance.
	 *
	 * @param string $url Candidate base URL.
	 * @return true|WP_Error
	 */
	public static function validate_base_url( string $url ) {
		$url   = trim( $url );
		$parts = wp_parse_url( $url );

		if ( '' === $url || ! is_array( $parts ) || empty( $parts['host'] ) || empty( $parts['scheme'] ) ) {
			return new WP_Error(
				'texttango_invalid_base_url',
				__( 'Enter a full API base URL, for example https://app.texttango.com/api', 'texttango' )
			);
		}

		$scheme = strtolower( $parts['scheme'] );

		if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
			return new WP_Error(
				'texttango_invalid_base_url',
				__( 'The API base URL must start with https://', 'texttango' )
			);
		}

		if ( 'https' !== $scheme && ! self::is_local_host( (string) $parts['host'] ) ) {
			return new WP_Error(
				'texttango_insecure_base_url',
				__( 'The API base URL must use HTTPS. Plain HTTP is allowed only for local development hosts.', 'texttango' )
			);
		}

		return true;
	}

	/**
	 * Whether a hostname belongs to a local development environment.
	 *
	 * @param string $host Hostname.
	 */
	public static function is_local_host( string $host ): bool {
		$host = strtolower( $host );

		if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1', '[::1]' ), true ) ) {
			return true;
		}

		foreach ( array( '.test', '.local', '.localhost', '.ddev.site', '.lndo.site' ) as $suffix ) {
			if ( str_ends_with( $host, $suffix ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Perform a GET request.
	 *
	 * @param string               $path  Path below the base URL, e.g. /v2/wallet.
	 * @param array<string, mixed> $query Query arguments.
	 * @param int                  $retries Retry attempts for transient failures.
	 * @return array<string, mixed>|WP_Error Decoded response body.
	 */
	public function get( string $path, array $query = array(), int $retries = 0 ) {
		if ( ! empty( $query ) ) {
			$path .= ( str_contains( $path, '?' ) ? '&' : '?' ) . http_build_query( $query );
		}

		return $this->request( 'GET', $path, null, $retries );
	}

	/**
	 * Perform a POST request.
	 *
	 * Never retries — see the class docblock.
	 *
	 * @param string               $path Path below the base URL.
	 * @param array<string, mixed> $body JSON body.
	 * @return array<string, mixed>|WP_Error Decoded response body.
	 */
	public function post( string $path, array $body = array() ) {
		return $this->request( 'POST', $path, $body, 0 );
	}

	/**
	 * Account balance (spec §4).
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public function wallet() {
		return $this->get( '/v2/wallet' );
	}

	/**
	 * Sender IDs registered on the account.
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public function sender_ids() {
		return $this->get( '/v2/sender-ids' );
	}

	/**
	 * Send one transactional SMS.
	 *
	 * @param string      $to      Recipient in E.164.
	 * @param string      $message Message body.
	 * @param string|null $from    Sender ID; falls back to the configured default.
	 * @return array<string, mixed>|WP_Error
	 */
	public function send_transactional( string $to, string $message, ?string $from = null ) {
		$payload = array(
			'to'      => $to,
			'message' => $message,
		);

		if ( null !== $from && '' !== $from ) {
			$payload['from'] = $from;
		}

		return $this->post( '/v2/sms/transactional/send', $payload );
	}

	/**
	 * The token's webhook configuration (spec §9, §20).
	 *
	 * Reports the callback URLs and whether a signing secret exists. The secret
	 * itself is never returned by this endpoint — only {@see self::rotate_webhook_secret()}
	 * ever reveals one, and only once.
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public function webhook_config() {
		return $this->get( '/v2/webhooks', array(), 2 );
	}

	/**
	 * Change the token's callback URLs.
	 *
	 * Only the fields supplied are altered, which is why callers pass exactly the
	 * one they mean to own rather than the whole object.
	 *
	 * @param array<string, mixed> $fields Any of `single_sms_callback_url`,
	 *                                     `campaign_callback_url`,
	 *                                     `single_whatsapp_callback_url`, `enabled`.
	 * @return array<string, mixed>|WP_Error
	 */
	public function update_webhook( array $fields ) {
		return $this->request( 'PATCH', '/v2/webhooks', $fields, 0 );
	}

	/**
	 * Issue a new webhook signing secret.
	 *
	 * Destructive and not idempotent: the previous secret stops verifying the
	 * moment this returns, and the new one is disclosed in this response only. It
	 * inherits the no-retry rule for POSTs, which matters more here than anywhere
	 * else — a retry after a timeout would mint a second secret and leave the site
	 * holding the first.
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public function rotate_webhook_secret() {
		return $this->post( '/v2/webhooks/rotate-secret' );
	}

	/**
	 * Ask TextTango to send a synthetic signed event to the configured callback.
	 *
	 * Delivered by a queued job rather than in this response, so the answer means
	 * "accepted for sending", not "arrived". Arrival is observed at the receiving
	 * end — see {@see \TextTango\Webhook\Registration}.
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public function test_webhook() {
		return $this->post( '/v2/webhooks/test' );
	}

	/**
	 * Create a campaign (spec §8).
	 *
	 * Takes an explicit recipient list rather than a contact group id, so the
	 * audience is the one this site decided on — see
	 * {@see \TextTango\Marketing\Audience} for why that difference matters.
	 *
	 * Inherits the no-retry rule for POSTs, which matters more here than
	 * anywhere: a retried campaign is a second campaign, to everybody.
	 *
	 * @param array<string, mixed> $payload Campaign fields.
	 * @return array<string, mixed>|WP_Error
	 */
	public function create_campaign( array $payload ) {
		return $this->post( '/v2/campaigns', $payload );
	}

	/**
	 * A campaign with its analytics.
	 *
	 * @param string $uuid Campaign UUID.
	 * @return array<string, mixed>|WP_Error
	 */
	public function campaign( string $uuid ) {
		return $this->get( '/v2/campaigns/' . rawurlencode( $uuid ), array(), 2 );
	}

	/**
	 * Stop a campaign that has not finished sending.
	 *
	 * @param string $uuid Campaign UUID.
	 * @return array<string, mixed>|WP_Error
	 */
	public function cancel_campaign( string $uuid ) {
		return $this->post( '/v2/campaigns/' . rawurlencode( $uuid ) . '/cancel' );
	}

	/**
	 * Contact groups on the account (spec §7).
	 *
	 * @return array<string, mixed>|WP_Error
	 */
	public function contact_groups() {
		return $this->get( '/v2/contact-groups', array( 'per_page' => 100 ) );
	}

	/**
	 * Add a contact to a group.
	 *
	 * Returns `texttango_duplicate_contact` when the number is already in the
	 * group — the endpoint answers 409 rather than upserting, so the caller has
	 * to look the existing contact up to learn its id.
	 *
	 * @param array<string, mixed> $attributes Contact fields; `mobile` is required.
	 * @return array<string, mixed>|WP_Error
	 */
	public function create_contact( array $attributes ) {
		return $this->post( '/v2/contacts', $attributes );
	}

	/**
	 * Update a contact.
	 *
	 * @param string               $uuid       Contact UUID.
	 * @param array<string, mixed> $attributes Fields to change.
	 * @return array<string, mixed>|WP_Error
	 */
	public function update_contact( string $uuid, array $attributes ) {
		return $this->request( 'PATCH', '/v2/contacts/' . rawurlencode( $uuid ), $attributes, 0 );
	}

	/**
	 * Remove a contact.
	 *
	 * @param string $uuid Contact UUID.
	 * @return array<string, mixed>|WP_Error
	 */
	public function delete_contact( string $uuid ) {
		return $this->request( 'DELETE', '/v2/contacts/' . rawurlencode( $uuid ), null, 0 );
	}

	/**
	 * Remove several contacts in one call.
	 *
	 * The one bulk endpoint worth using. Deleting by UUID is naturally idempotent —
	 * asking twice removes the same rows — which is not true of the bulk *create*
	 * endpoint; see {@see \TextTango\Contacts\ContactReconciler} for why that one is
	 * deliberately left alone.
	 *
	 * @param string[] $uuids Contact UUIDs, up to the API's limit of 1000.
	 * @return array<string, mixed>|WP_Error
	 */
	public function delete_contacts( array $uuids ) {
		return $this->request(
			'DELETE',
			'/v2/contacts/bulk',
			array( 'contact_ids' => array_values( $uuids ) ),
			0
		);
	}

	/**
	 * Find a contact by number, optionally within one group.
	 *
	 * The way back from a 409: the create endpoint refuses a duplicate without
	 * saying which contact it clashed with, so the id has to be searched for.
	 *
	 * @param string $mobile   Number in E.164.
	 * @param string $group_id Group UUID, or an empty string for any group.
	 * @return array<string, mixed>|WP_Error
	 */
	public function find_contact( string $mobile, string $group_id = '' ) {
		$query = array(
			'search'   => $mobile,
			'per_page' => 10,
		);

		if ( '' !== $group_id ) {
			$query['group_id'] = $group_id;
		}

		return $this->get( '/v2/contacts', $query, 2 );
	}

	/**
	 * Current status of a previously sent message (spec §19 self-heal).
	 *
	 * @param string $uuid TextTango message UUID.
	 * @return array<string, mixed>|WP_Error
	 */
	public function message_status( string $uuid ) {
		return $this->get( '/v2/sms/messages/' . rawurlencode( $uuid ), array(), 2 );
	}

	/**
	 * Issue a request, optionally retrying transient failures.
	 *
	 * @param string                    $method  HTTP method.
	 * @param string                    $path    Path below the base URL.
	 * @param array<string, mixed>|null $body    JSON body, if any.
	 * @param int                       $retries Retry attempts remaining.
	 * @return array<string, mixed>|WP_Error
	 */
	protected function request( string $method, string $path, ?array $body, int $retries ) {
		$attempt = 0;

		do {
			$result = $this->dispatch( $method, $path, $body );

			if ( ! is_wp_error( $result ) || ! $this->is_retryable( $result ) || $attempt >= $retries ) {
				return $result;
			}

			$this->sleep_seconds( $this->backoff_seconds( $attempt, $result ) );
			++$attempt;
		} while ( true );
	}

	/**
	 * Issue exactly one request.
	 *
	 * @param string                    $method HTTP method.
	 * @param string                    $path   Path below the base URL.
	 * @param array<string, mixed>|null $body   JSON body, if any.
	 * @return array<string, mixed>|WP_Error
	 */
	private function dispatch( string $method, string $path, ?array $body ) {
		$host = (string) wp_parse_url( $this->base_url, PHP_URL_HOST );

		$args = array(
			'method'      => $method,
			'timeout'     => $this->timeout,
			// An API endpoint should answer directly; a redirect means the base URL
			// is pointing somewhere else (a login page, say) and should be reported.
			'redirection' => 0,
			/*
			 * Local development hosts serve self-signed certificates, which would
			 * otherwise fail verification and make the plugin untestable against a
			 * local instance. WordPress core makes the same exception for local
			 * requests via `https_local_ssl_verify`. Never relaxed for a real host.
			 */
			'sslverify'   => ! self::is_local_host( $host ),
			'headers'     => array(
				'Authorization' => 'Bearer ' . $this->token,
				'Accept'        => 'application/json',
				'Content-Type'  => 'application/json',
			),
			'user-agent'  => $this->user_agent(),
		);

		if ( null !== $body ) {
			$args['body'] = wp_json_encode( $body );
		}

		$url = $this->base_url . '/' . ltrim( $path, '/' );

		/**
		 * Filters the arguments passed to the WordPress HTTP API for a TextTango request.
		 *
		 * @param array<string, mixed> $args   Request arguments.
		 * @param string               $url    Full request URL.
		 * @param string               $method HTTP method.
		 */
		$args = apply_filters( 'texttango_http_request_args', $args, $url, $method );

		return $this->interpret( wp_remote_request( $url, $args ) );
	}

	/**
	 * Turn a raw WordPress HTTP response into decoded data or a typed WP_Error.
	 *
	 * @param array<string, mixed>|WP_Error $response Raw response.
	 * @return array<string, mixed>|WP_Error
	 */
	private function interpret( $response ) {
		if ( is_wp_error( $response ) ) {
			$message = $response->get_error_message();

			if ( false !== stripos( $message, 'timed out' ) || false !== stripos( $message, 'timeout' ) ) {
				return new WP_Error(
					'texttango_timeout',
					__( 'The request to TextTango timed out.', 'texttango' ),
					array( 'status' => 0 )
				);
			}

			return new WP_Error(
				'texttango_transport_error',
				sprintf(
					/* translators: %s: underlying HTTP error message. */
					__( 'Could not reach TextTango: %s', 'texttango' ),
					$message
				),
				array( 'status' => 0 )
			);
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		$raw    = (string) wp_remote_retrieve_body( $response );
		$data   = json_decode( $raw, true );

		if ( $status >= 300 && $status < 400 ) {
			return new WP_Error(
				'texttango_unexpected_redirect',
				__( 'The API base URL redirected instead of responding. Check that it points at the API root, for example https://app.texttango.com/api', 'texttango' ),
				array( 'status' => $status )
			);
		}

		if ( $status >= 200 && $status < 300 ) {
			if ( ! is_array( $data ) ) {
				return new WP_Error(
					'texttango_invalid_response',
					__( 'TextTango returned a response that could not be read as JSON.', 'texttango' ),
					array( 'status' => $status )
				);
			}

			return $data;
		}

		return $this->error_from_body( $status, is_array( $data ) ? $data : array(), $response );
	}

	/**
	 * The useful sentence out of a validation failure.
	 *
	 * A rejected field comes back in a different shape from the documented error
	 * envelope — `{"message": "…", "errors": {"field": ["…"]}}`, keyed by field
	 * rather than a list — so the envelope reader finds nothing and the caller is
	 * told only that "the request failed". That turns an answerable problem, like
	 * a callback URL the platform will not accept, into a dead end. The
	 * field-level message is preferred over the summary because it is the one
	 * that says which field and why.
	 *
	 * @param array<string, mixed> $data Decoded body.
	 */
	private static function validation_message( array $data ): string {
		$errors = $data['errors'] ?? array();

		if ( is_array( $errors ) ) {
			foreach ( $errors as $messages ) {
				if ( is_string( $messages ) && '' !== $messages ) {
					return $messages;
				}

				if ( is_array( $messages ) && isset( $messages[0] ) && is_string( $messages[0] ) ) {
					return $messages[0];
				}
			}
		}

		return isset( $data['message'] ) && is_string( $data['message'] ) ? $data['message'] : '';
	}

	/**
	 * Map a v2 error envelope to a WP_Error (spec §15, Appendix A).
	 *
	 * @param int                           $status   HTTP status code.
	 * @param array<string, mixed>          $data     Decoded body.
	 * @param array<string, mixed>|WP_Error $response Raw response, for headers.
	 */
	private function error_from_body( int $status, array $data, $response ): WP_Error {
		$first     = isset( $data['errors'][0] ) && is_array( $data['errors'][0] ) ? $data['errors'][0] : array();
		$api_code  = isset( $first['code'] ) ? (string) $first['code'] : '';
		$detail    = isset( $first['detail'] ) ? (string) $first['detail'] : '';
		$title     = isset( $first['title'] ) ? (string) $first['title'] : '';
		$api_error = '' !== $detail ? $detail : $title;

		if ( '' === $api_error ) {
			$api_error = self::validation_message( $data );
		}

		$error_data = array(
			'status'   => $status,
			'api_code' => $api_code,
		);

		if ( 429 === $status ) {
			$error_data['retry_after'] = $this->retry_after( $data, $response );

			return new WP_Error(
				'texttango_rate_limited',
				__( 'TextTango is rate limiting this site. The send will be retried shortly.', 'texttango' ),
				$error_data
			);
		}

		$known = array(
			401 => array(
				'texttango_unauthorized',
				__( 'The API token was rejected. It may be invalid, revoked, or missing the required abilities.', 'texttango' ),
			),
			402 => array(
				'texttango_insufficient_balance',
				__( 'Your TextTango balance is too low to send. Top up to resume sending.', 'texttango' ),
			),
			403 => array(
				'texttango_forbidden',
				__( 'The API token does not have the abilities this action requires.', 'texttango' ),
			),
			404 => array(
				'texttango_not_found',
				__( 'TextTango could not find that resource.', 'texttango' ),
			),
		);

		if ( isset( $known[ $status ] ) ) {
			return new WP_Error(
				$known[ $status ][0],
				'' !== $api_error ? $api_error : $known[ $status ][1],
				$error_data
			);
		}

		if ( $status >= 500 ) {
			return new WP_Error(
				'texttango_server_error',
				'' !== $api_error ? $api_error : __( 'TextTango is temporarily unavailable.', 'texttango' ),
				$error_data
			);
		}

		return new WP_Error(
			'' !== $api_code ? 'texttango_' . strtolower( $api_code ) : 'texttango_request_failed',
			'' !== $api_error ? $api_error : __( 'The request to TextTango failed.', 'texttango' ),
			$error_data
		);
	}

	/**
	 * Seconds TextTango asked us to wait, from the header or the body.
	 *
	 * @param array<string, mixed>          $data     Decoded body.
	 * @param array<string, mixed>|WP_Error $response Raw response.
	 */
	private function retry_after( array $data, $response ): int {
		$header = wp_remote_retrieve_header( $response, 'retry-after' );

		if ( is_array( $header ) ) {
			$header = reset( $header );
		}

		if ( is_numeric( $header ) ) {
			return max( 0, (int) $header );
		}

		foreach ( array( $data['retry_after'] ?? null, $data['meta']['retry_after'] ?? null ) as $candidate ) {
			if ( is_numeric( $candidate ) ) {
				return max( 0, (int) $candidate );
			}
		}

		return 0;
	}

	/**
	 * Whether an error is worth trying again.
	 *
	 * @param WP_Error $error Error to classify.
	 */
	private function is_retryable( WP_Error $error ): bool {
		return in_array(
			$error->get_error_code(),
			array( 'texttango_rate_limited', 'texttango_server_error', 'texttango_transport_error', 'texttango_timeout' ),
			true
		);
	}

	/**
	 * How long to wait before the next attempt: TextTango's own figure when it
	 * gave one, otherwise exponential backoff with jitter — both capped, so a
	 * long `retry_after` never stalls a web request.
	 *
	 * @param int      $attempt Zero-based attempt number already made.
	 * @param WP_Error $error   Error from that attempt.
	 */
	private function backoff_seconds( int $attempt, WP_Error $error ): float {
		$data        = $error->get_error_data();
		$retry_after = is_array( $data ) && isset( $data['retry_after'] ) ? (int) $data['retry_after'] : 0;

		if ( $retry_after > 0 ) {
			return (float) min( $retry_after, self::MAX_BACKOFF_SECONDS );
		}

		$base   = min( 2 ** $attempt, self::MAX_BACKOFF_SECONDS );
		$jitter = wp_rand( 0, 250 ) / 1000;

		return (float) min( $base + $jitter, self::MAX_BACKOFF_SECONDS );
	}

	/**
	 * Pause between retries. Overridden in tests.
	 *
	 * @param float $seconds Seconds to wait.
	 */
	protected function sleep_seconds( float $seconds ): void {
		if ( $seconds > 0 ) {
			usleep( (int) round( $seconds * 1000000 ) );
		}
	}

	/**
	 * Identify this site to the API.
	 */
	private function user_agent(): string {
		return sprintf(
			'TextTango-WP/%s; WordPress/%s; %s',
			TEXTTANGO_VERSION,
			get_bloginfo( 'version' ),
			home_url( '/' )
		);
	}
}
