<?php
/**
 * Google Sheets API v4 client.
 *
 * @package StoreSheet
 */

declare( strict_types=1 );

namespace StoreSheet\API;

defined( 'ABSPATH' ) || exit;

/**
 * Thin wrapper around Google Sheets API v4.
 * All methods return data arrays or WP_Error.
 */
class GoogleSheetsClient extends ApiClient {

	private const BASE_URL = 'https://sheets.googleapis.com/v4/spreadsheets/';

	private GoogleOAuthClient $oauth;
	private string $spreadsheet_id;

	public function __construct( GoogleOAuthClient $oauth ) {
		$this->oauth          = $oauth;
		$settings             = get_option( 'storesheet_settings', array() );
		$this->spreadsheet_id = $settings['spreadsheet_id'] ?? '';
	}

	// -------------------------------------------------------------------------
	// Read
	// -------------------------------------------------------------------------

	/**
	 * Read a single range.
	 *
	 * @param string $range A1 notation, e.g. "Products!A1:Z500".
	 * @return array|\WP_Error {values: array[][]}
	 */
	public function get_values( string $range ) {
		return $this->request(
			'GET',
			$this->spreadsheet_id . '/values/' . rawurlencode( $range )
		);
	}

	/**
	 * Read multiple ranges in one request.
	 *
	 * @param string[] $ranges Array of A1 notation ranges.
	 * @return array|\WP_Error {valueRanges: array[]}
	 */
	public function batch_get( array $ranges ) {
		$query = implode( '&', array_map( static fn( $r ) => 'ranges=' . rawurlencode( $r ), $ranges ) );
		return $this->request(
			'GET',
			$this->spreadsheet_id . '/values:batchGet?' . $query
		);
	}

	// -------------------------------------------------------------------------
	// Write
	// -------------------------------------------------------------------------

	/**
	 * Write multiple ranges in one request (100s of rows = 1 API call).
	 *
	 * @param array  $data              Array of {range: string, values: array[][]} items.
	 * @param string $value_input_option USER_ENTERED | RAW.
	 * @return array|\WP_Error
	 */
	public function batch_update( array $data, string $value_input_option = 'USER_ENTERED' ) {
		foreach ( $data as $i => $item ) {
			if ( isset( $item['values'] ) && is_array( $item['values'] ) ) {
				$data[ $i ]['values'] = self::neutralize_formulas( $item['values'] );
			}
		}

		return $this->request(
			'POST',
			$this->spreadsheet_id . '/values:batchUpdate',
			array(
				'valueInputOption' => $value_input_option,
				'data'             => $data,
			)
		);
	}

	/**
	 * Append rows after the last table in the range.
	 *
	 * @param string  $range  Target range / sheet name.
	 * @param array[] $values Row arrays to append.
	 * @return array|\WP_Error
	 */
	public function append( string $range, array $values ) {
		return $this->request(
			'POST',
			$this->spreadsheet_id . '/values/' . rawurlencode( $range ) . ':append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS',
			array( 'values' => self::neutralize_formulas( $values ) )
		);
	}

	/**
	 * Neutralize spreadsheet formula / CSV injection.
	 *
	 * With valueInputOption=USER_ENTERED, Google Sheets evaluates any cell whose
	 * text begins with =, +, -, or @ as a live formula. Store data can carry
	 * attacker-controlled text (e.g. a checkout billing name or customer note),
	 * so prefix any such non-numeric string cell with an apostrophe — Sheets then
	 * treats it as literal text and does not render the apostrophe. Numeric
	 * values (including negative numbers and prices) pass through untouched so
	 * they still store as numbers.
	 *
	 * @param array[] $values 2D array of row/cell values.
	 * @return array[]
	 */
	private static function neutralize_formulas( array $values ): array {
		foreach ( $values as $r => $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			foreach ( $row as $c => $cell ) {
				if (
					is_string( $cell )
					&& '' !== $cell
					&& ! is_numeric( $cell )
					&& in_array( $cell[0], array( '=', '+', '-', '@', "\t", "\r" ), true )
				) {
					$values[ $r ][ $c ] = "'" . $cell;
				}
			}
		}

		return $values;
	}

	/**
	 * Clear a range.
	 *
	 * @param string $range A1 notation range.
	 * @return array|\WP_Error
	 */
	public function clear( string $range ) {
		return $this->request(
			'POST',
			$this->spreadsheet_id . '/values/' . rawurlencode( $range ) . ':clear'
		);
	}

	// -------------------------------------------------------------------------
	// Spreadsheet structure
	// -------------------------------------------------------------------------

	/**
	 * Get spreadsheet metadata (title, sheets list, etc.).
	 *
	 * @param string|null $spreadsheet_id Defaults to configured ID.
	 * @return array|\WP_Error
	 */
	public function get_spreadsheet( ?string $spreadsheet_id = null ) {
		$id = $spreadsheet_id ?: $this->spreadsheet_id;
		return $this->request( 'GET', $id . '?includeGridData=false' );
	}

	/**
	 * Execute a batch of structural requests (add sheet, freeze rows, protect range, etc.).
	 *
	 * @param array $requests Array of Sheets API request objects.
	 * @return array|\WP_Error
	 */
	public function batch_update_spreadsheet( array $requests ) {
		return $this->request(
			'POST',
			$this->spreadsheet_id . ':batchUpdate',
			array( 'requests' => $requests )
		);
	}

	/**
	 * Create a new spreadsheet.
	 *
	 * @param string $title Spreadsheet title.
	 * @return array|\WP_Error Response includes spreadsheetId and spreadsheetUrl.
	 */
	public function create_spreadsheet( string $title ) {
		return $this->request(
			'POST',
			'',
			array(
				'properties' => array( 'title' => $title ),
			),
			// Create hits the collection endpoint. The trailing slash on BASE_URL
			// makes Google read the URL as /spreadsheets/{empty-id} and 404s, so
			// strip it here.
			rtrim( self::BASE_URL, '/' )
		);
	}

	// -------------------------------------------------------------------------
	// Internal
	// -------------------------------------------------------------------------

	/**
	 * @param string      $method  HTTP method.
	 * @param string      $path    Path relative to BASE_URL (or absolute via $base override).
	 * @param array       $body    JSON body (for POST).
	 * @param string|null $base    Override base URL.
	 * @return array|\WP_Error
	 */
	private function request( string $method, string $path, array $body = array(), ?string $base = null ) {
		$access_token = $this->oauth->get_access_token();
		if ( is_wp_error( $access_token ) ) {
			return $access_token;
		}

		$url = ( $base ?? self::BASE_URL ) . $path;

		/** @var array{headers: array<string, string>, body?: string} $args */
		$args = array(
			'headers' => array(
				'Authorization' => 'Bearer ' . $access_token,
				'Content-Type'  => 'application/json',
			),
		);

		if ( ! empty( $body ) ) {
			$args['body'] = wp_json_encode( $body ) ?: '';
		}

		return $this->http_request( $method, $url, $args );
	}

	/**
	 * @param int          $code HTTP status code.
	 * @param array<mixed> $data Decoded response body.
	 */
	protected function error_from_response( int $code, array $data ): \WP_Error {
		if ( 429 === $code ) {
			return new \WP_Error( 'rate_limited', __( 'Google Sheets API rate limit exceeded. Will retry.', 'storesheet' ), array( 'status' => 429 ) );
		}

		return new \WP_Error(
			'sheets_api_error',
			$data['error']['message'] ?? sprintf( 'HTTP %d', $code ),
			array(
				'status' => $code,
				'data'   => $data,
			)
		);
	}
}
