<?php
/**
 * HTTP client for the Attio REST API v2
 *
 * @package WooAttioSync
 */

declare( strict_types=1 );

defined( 'ABSPATH' ) || exit;

/**
 * Thin wrapper around wp_remote_request() that targets the Attio v2 API.
 *
 * API key resolution order:
 *   1. $api_key constructor argument (when passed explicitly)
 *   2. WCATTIO_API_KEY constant (defined in wp-config.php)
 *   3. wcattio_api_key wp_option
 */
class WooAttioSync_Attio_Client {

	private const API_BASE = 'https://api.attio.com/v2';

	private const TIMEOUT = 15;

	private string $api_key;

	/**
	 * @param string $api_key Optional API key; falls back to constant or wp_option
	 */
	public function __construct( string $api_key = '' ) {
		if ( '' !== $api_key ) {
			$this->api_key = $api_key;
		} elseif ( defined( 'WCATTIO_API_KEY' ) && '' !== WCATTIO_API_KEY ) {
			$this->api_key = (string) WCATTIO_API_KEY;
		} else {
			$this->api_key = (string) get_option( 'wcattio_api_key', '' );
		}
	}

	/**
	 * Upsert an Attio People record matched by email address.
	 *
	 * @param string               $email      The canonical email to match/create on
	 * @param array<string,mixed>  $attributes Additional attribute values to set
	 * @return array<string,mixed>|WP_Error
	 */
	public function upsert_person( string $email, array $attributes = array() ): array|WP_Error {
		if ( '' === $email ) {
			return new WP_Error(
				'wcattio_missing_email',
				__( 'Email address is required to upsert a People record.', 'harbor-sync-with-attio-for-woocommerce' )
			);
		}

		$values                    = $attributes;
		$values['email_addresses'] = array( array( 'email_address' => $email ) );

		return $this->request(
			'PUT',
			'/objects/people/records',
			array(
				'data' => array(
					'values' => $values,
				),
			),
			array( 'matching_attribute' => 'email_addresses' )
		);
	}

	/**
	 * Fetch a single People record by its Attio record ID
	 *
	 * @param string $record_id Attio record ID (UUID)
	 * @return array<string,mixed>|WP_Error
	 */
	public function get_person( string $record_id ): array|WP_Error {
		return $this->get_record( 'people', $record_id );
	}

	/**
	 * Fetch a single record from any object.
	 *
	 * Attio accepts either an object slug ("people") or the object UUID, which is
	 * what webhook events carry.
	 *
	 * @param string $object_ref Object slug or UUID.
	 * @param string $record_id  Attio record ID (UUID).
	 * @return array<string,mixed>|WP_Error
	 */
	public function get_record( string $object_ref, string $record_id ): array|WP_Error {
		return $this->request(
			'GET',
			'/objects/' . rawurlencode( $object_ref ) . '/records/' . rawurlencode( $record_id )
		);
	}

	/**
	 * Upsert an Attio Companies record matched by domain name (when supplied) or by name.
	 *
	 * @param string               $name       Company display name
	 * @param string               $domain     Optional primary domain (e.g. "acme.com")
	 * @param array<string,mixed>  $attributes Additional attribute values to set
	 * @return array<string,mixed>|WP_Error
	 */
	public function upsert_company( string $name, string $domain = '', array $attributes = array() ): array|WP_Error {
		$values = array_merge( array( 'name' => array( array( 'value' => $name ) ) ), $attributes );

		if ( '' !== $domain ) {
			$values['domains'] = array( array( 'domain' => $domain ) );
		}

		$matching_attribute = '' !== $domain ? 'domains' : 'name';

		return $this->request(
			'PUT',
			'/objects/companies/records',
			array(
				'data' => array(
					'values' => $values,
				),
			),
			array( 'matching_attribute' => $matching_attribute )
		);
	}

	/**
	 * Set a People record's company reference so the person and company are
	 * associated in Attio's relationship graph rather than left as loose records.
	 *
	 * @param string $person_record_id  Attio People record ID.
	 * @param string $company_record_id Attio Companies record ID.
	 * @return array<string,mixed>|WP_Error
	 */
	public function link_person_to_company( string $person_record_id, string $company_record_id ): array|WP_Error {
		return $this->update_person(
			$person_record_id,
			array(
				'company' => array(
					array(
						'target_object'    => 'companies',
						'target_record_id' => $company_record_id,
					),
				),
			)
		);
	}

	/**
	 * PATCH attribute values onto an existing People record.
	 *
	 * @param string              $record_id People record ID.
	 * @param array<string,mixed> $values    Attribute slug => Attio value payload.
	 * @return array<string,mixed>|WP_Error
	 */
	public function update_person( string $record_id, array $values ): array|WP_Error {
		return $this->request(
			'PATCH',
			'/objects/people/records/' . rawurlencode( $record_id ),
			array( 'data' => array( 'values' => $values ) )
		);
	}

	/**
	 * List the attribute slugs defined on an object.
	 *
	 * @param string $object_slug Object slug.
	 * @return array<int,string>|WP_Error Slugs, or WP_Error on failure.
	 */
	public function list_attribute_slugs( string $object_slug ): array|WP_Error {
		$result = $this->request( 'GET', '/objects/' . rawurlencode( $object_slug ) . '/attributes' );
		if ( is_wp_error( $result ) ) {
			return $result;
		}
		return array_values(
			array_filter(
				array_map(
					static fn( $a ) => is_array( $a ) ? ( $a['api_slug'] ?? '' ) : '',
					$result['data'] ?? array()
				)
			)
		);
	}

	/**
	 * Create a custom attribute on an object. Attio requires the full descriptor.
	 *
	 * @param string $object_slug Object slug.
	 * @param array<string,mixed> $data   Attribute descriptor (title, api_slug, type, config...).
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_attribute( string $object_slug, array $data ): array|WP_Error {
		return $this->request(
			'POST',
			'/objects/' . rawurlencode( $object_slug ) . '/attributes',
			array( 'data' => $data )
		);
	}

	/**
	 * Fetch an object's definition (used to detect whether e.g. the Deals object
	 * is enabled in the workspace).
	 *
	 * @param string $object_slug Object slug.
	 * @return array<string,mixed>|WP_Error
	 */
	public function get_object( string $object_slug ): array|WP_Error {
		return $this->request( 'GET', '/objects/' . rawurlencode( $object_slug ) );
	}

	/**
	 * Upsert a record into any object, matched on a given attribute.
	 *
	 * @param string              $object             Object slug.
	 * @param string              $matching_attribute Attribute to dedupe on.
	 * @param array<string,mixed> $values             Attribute values to write.
	 * @return array<string,mixed>|WP_Error
	 */
	public function upsert_record( string $object_slug, string $matching_attribute, array $values ): array|WP_Error {
		return $this->request(
			'PUT',
			'/objects/' . rawurlencode( $object_slug ) . '/records',
			array( 'data' => array( 'values' => $values ) ),
			array( 'matching_attribute' => $matching_attribute )
		);
	}

	/**
	 * Create an Attio Task, optionally linked to records.
	 *
	 * @param string                       $content    Plaintext task content.
	 * @param string                       $deadline   ISO-8601 deadline, or '' for none.
	 * @param array<int,array<string,string>> $links   linked_records entries.
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_task( string $content, string $deadline = '', array $links = array() ): array|WP_Error {
		$data = array(
			'content'        => $content,
			'format'         => 'plaintext',
			'is_completed'   => false,
			'linked_records' => $links,
			'assignees'      => array(),
		);
		if ( '' !== $deadline ) {
			$data['deadline_at'] = $deadline;
		}
		return $this->request( 'POST', '/tasks', array( 'data' => $data ) );
	}

	/**
	 * Create a plaintext note attached to any Attio record
	 *
	 * @param string $parent_object Attio object slug, e.g. "people" or "companies"
	 * @param string $record_id     UUID of the parent record
	 * @param string $title         Note title
	 * @param string $content       Note body (plaintext)
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_note(
		string $parent_object,
		string $record_id,
		string $title,
		string $content
	): array|WP_Error {
		return $this->request(
			'POST',
			'/notes',
			array(
				'data' => array(
					'parent_object'    => $parent_object,
					'parent_record_id' => $record_id,
					'title'            => $title,
					'content'          => $content,
					'format'           => 'plaintext',
				),
			)
		);
	}

	/**
	 * Fetch all Attio lists visible to the API key
	 *
	 * @return array<string,mixed>|WP_Error
	 */
	public function get_lists(): array|WP_Error {
		return $this->request( 'GET', '/lists' );
	}

	/**
	 * Whether a People record is already an entry on the given list.
	 *
	 * Attio's POST /entries is not idempotent — it creates a fresh entry on every
	 * call — so callers must check first to avoid duplicate list memberships.
	 *
	 * @param string $list_id   Attio list ID (UUID)
	 * @param string $record_id Attio People record ID (UUID)
	 * @return bool|WP_Error True when an entry exists, WP_Error on API failure
	 */
	public function list_has_entry( string $list_id, string $record_id ): bool|WP_Error {
		$result = $this->request(
			'POST',
			'/lists/' . rawurlencode( $list_id ) . '/entries/query',
			array(
				'filter' => array(
					'parent_record' => array( 'target_record_id' => $record_id ),
				),
				'limit'  => 1,
			)
		);

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

		return ! empty( $result['data'] );
	}

	/**
	 * Add a People record to an Attio list.
	 *
	 * Attio requires an entry_values object (even when empty) and creates a
	 * duplicate entry on each call, so this skips the add when the record is
	 * already on the list.
	 *
	 * @param string $list_id   Attio list ID (UUID)
	 * @param string $record_id Attio People record ID (UUID)
	 * @return array<string,mixed>|WP_Error
	 */
	public function add_list_entry( string $list_id, string $record_id ): array|WP_Error {
		$exists = $this->list_has_entry( $list_id, $record_id );
		if ( is_wp_error( $exists ) ) {
			return $exists;
		}
		if ( $exists ) {
			return array( 'data' => array( 'already_member' => true ) );
		}

		return $this->request(
			'POST',
			'/lists/' . rawurlencode( $list_id ) . '/entries',
			array(
				'data' => array(
					'parent_object'    => 'people',
					'parent_record_id' => $record_id,
					'entry_values'     => (object) array(),
				),
			)
		);
	}

	/**
	 * Whether an API key is configured via the WCATTIO_API_KEY constant or the
	 * wcattio_api_key option. This is the single source of truth for "is the
	 * plugin connected?" and mirrors the constructor's resolution order.
	 */
	public static function has_api_key(): bool {
		return ( defined( 'WCATTIO_API_KEY' ) && '' !== WCATTIO_API_KEY )
			|| '' !== (string) get_option( 'wcattio_api_key', '' );
	}

	/**
	 * Factory: return a new client if an API key is configured, or WP_Error if not
	 *
	 * @return static|WP_Error
	 */
	public static function create(): static|WP_Error {
		if ( ! self::has_api_key() ) {
			return new WP_Error( 'missing_api_key', 'Attio API key is not configured' );
		}

		return new static();
	}

	/**
	 * Test whether the stored API key is valid by calling GET /v2/self
	 *
	 * @return bool True when the workspace responds with HTTP 200
	 */
	public function validate_api_key(): bool {
		if ( '' === $this->api_key ) {
			return false;
		}

		$response = $this->request( 'GET', '/self' );

		return ! is_wp_error( $response );
	}

	/**
	 * Error codes that are permanent plugin-level failures, never retryable.
	 * Status-0 normally means a network error (retryable), but these carry no
	 * HTTP context and must not trigger AS retries.
	 */
	private const PERMANENT_ERROR_CODES = array(
		'wcattio_no_api_key',
		'wcattio_missing_email',
		'wcattio_json_encode_error',
	);

	/**
	 * Returns true when the WP_Error represents a transient failure that
	 * Action Scheduler should retry (network errors, 429 rate limit, 5xx).
	 *
	 * @param WP_Error $error The error returned from request().
	 */
	public static function is_transient_error( WP_Error $error ): bool {
		$code = $error->get_error_code();
		// Rate-limited errors are handled with a scheduled reschedule in the sync queue;
		// they must not also trigger the generic RuntimeException throw path
		if ( 'wcattio_rate_limited' === $code ) {
			return false;
		}
		if ( in_array( $code, self::PERMANENT_ERROR_CODES, true ) ) {
			return false;
		}
		$data   = $error->get_error_data();
		$status = (int) ( is_array( $data ) ? ( $data['status'] ?? 0 ) : 0 );
		return $status >= 500 || 0 === $status;
	}

	/**
	 * Parse a Retry-After header into seconds.
	 *
	 * RFC 7231 permits either delta-seconds or an HTTP-date, and Attio sends the
	 * HTTP-date form ("Wed, 22 Jul 2026 21:25:52 GMT"). Casting that to int
	 * yields 0, so the requested delay would be silently discarded.
	 *
	 * @param string $header Raw Retry-After header value.
	 * @return int Seconds to wait; 0 when absent or unparseable.
	 */
	private static function parse_retry_after( string $header ): int {
		$header = trim( $header );
		if ( '' === $header ) {
			return 0;
		}

		if ( ctype_digit( $header ) ) {
			return (int) $header;
		}

		$when = strtotime( $header );
		if ( false === $when ) {
			return 0;
		}

		return max( 0, $when - time() );
	}

	/**
	 * Execute an HTTP request against the Attio API.
	 *
	 * @param string               $method   HTTP verb (GET, POST, PUT, PATCH, DELETE)
	 * @param string               $endpoint Path starting with "/", e.g. "/objects/people/records"
	 * @param array<string,mixed>  $body     Request body (JSON-encoded automatically)
	 * @param array<string,string> $query    Optional query-string parameters
	 * @return array<string,mixed>|WP_Error Decoded JSON body or WP_Error on failure
	 */
	private function request(
		string $method,
		string $endpoint,
		array $body = array(),
		array $query = array()
	): array|WP_Error {
		if ( '' === $this->api_key ) {
			return new WP_Error(
				'wcattio_no_api_key',
				__( 'Attio API key is not configured.', 'harbor-sync-with-attio-for-woocommerce' )
			);
		}

		$url = self::API_BASE . $endpoint;

		if ( ! empty( $query ) ) {
			$url = add_query_arg( $query, $url );
		}

		$args = array(
			'method'  => strtoupper( $method ),
			'timeout' => self::TIMEOUT,
			'headers' => array(
				'Authorization' => 'Bearer ' . $this->api_key,
				'Content-Type'  => 'application/json',
				'Accept'        => 'application/json',
			),
		);

		if ( ! empty( $body ) ) {
			$encoded = wp_json_encode( $body );
			if ( false === $encoded ) {
				return new WP_Error(
					'wcattio_json_encode_error',
					__( 'Failed to JSON-encode request body.', 'harbor-sync-with-attio-for-woocommerce' )
				);
			}
			$args['body'] = $encoded;
		}

		$response = wp_remote_request( $url, $args );

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

		$status_code = (int) wp_remote_retrieve_response_code( $response );
		$raw_body    = wp_remote_retrieve_body( $response );

		// Decode response body; detect non-JSON payloads from upstream proxies
		$decoded = '' !== $raw_body ? json_decode( $raw_body, true ) : null;
		if ( '' !== $raw_body && null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
			return new WP_Error(
				'wcattio_json_decode_error',
				__( 'Attio API returned a non-JSON response.', 'harbor-sync-with-attio-for-woocommerce' ),
				array(
					'status' => $status_code,
					'body'   => substr( $raw_body, 0, 500 ),
				)
			);
		}

		// Rate-limited — read Retry-After header; floor at 60s
		if ( 429 === $status_code ) {
			$retry_after = self::parse_retry_after( wp_remote_retrieve_header( $response, 'retry-after' ) );
			return new WP_Error(
				'wcattio_rate_limited',
				__( 'Attio API rate limit reached. Please try again later.', 'harbor-sync-with-attio-for-woocommerce' ),
				array(
					'status'      => 429,
					'retry_after' => max( 60, $retry_after ),
				)
			);
		}

		// Treat any non-2xx status as an error
		if ( $status_code < 200 || $status_code >= 300 ) {
			$message = '';

			if ( is_array( $decoded ) ) {
				// Attio error bodies carry a code and/or message string field
				$message = $decoded['message'] ?? $decoded['code'] ?? '';
			}

			if ( '' === $message ) {
				$message = sprintf(
					/* translators: 1: HTTP status code */
					__( 'Attio API returned HTTP %d', 'harbor-sync-with-attio-for-woocommerce' ),
					$status_code
				);
			}

			return new WP_Error(
				'wcattio_api_error_' . $status_code,
				$message,
				array(
					'status'   => $status_code,
					'response' => $decoded,
				)
			);
		}

		// Return the decoded body; fall back to an empty array for 204 No Content
		return is_array( $decoded ) ? $decoded : array();
	}
}
