<?php

namespace Aws\ClientSideMonitoring;

use Aws\CommandInterface;
use Aws\Exception\AwsException;
use Aws\MonitoringEventsInterface;
use Aws\ResponseContainerInterface;
use Aws\ResultInterface;
use Exception;
use GuzzleHttp\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Throwable;
use function Aws\default_user_agent;

/**
 * @internal
 */
abstract class AbstractMonitoringMiddleware
	implements MonitoringMiddlewareInterface {
	private static $socket;
	protected $credentialProvider;
	protected $region;
	protected $service;
	private $nextHandler;
	private $options;

	/**
	 * Constructor stores the passed in handler and options.
	 *
	 * @param callable $handler
	 * @param callable $credentialProvider
	 * @param $options
	 * @param $region
	 * @param $service
	 */
	public function __construct(
		callable $handler,
		callable $credentialProvider,
		$options,
		$region,
		$service
	) {
		$this->nextHandler        = $handler;
		$this->credentialProvider = $credentialProvider;
		$this->options            = $options;
		$this->region             = $region;
		$this->service            = $service;
	}

	protected static function getAwsExceptionHeader( AwsException $e, $headerName ) {
		$response = $e->getResponse();
		if ( $response !== null ) {
			$header = $response->getHeader( $headerName );
			if ( ! empty( $header[0] ) ) {
				return $header[0];
			}
		}

		return null;
	}

	protected static function getResultHeader( ResultInterface $result, $headerName ) {
		if ( isset( $result['@metadata']['headers'][ $headerName ] ) ) {
			return $result['@metadata']['headers'][ $headerName ];
		}

		return null;
	}

	protected static function getExceptionHeader( Exception $e, $headerName ) {
		if ( $e instanceof ResponseContainerInterface ) {
			$response = $e->getResponse();
			if ( $response instanceof ResponseInterface ) {
				$header = $response->getHeader( $headerName );
				if ( ! empty( $header[0] ) ) {
					return $header[0];
				}
			}
		}

		return null;
	}

	/**
	 * Standard invoke pattern for middleware execution to be implemented by
	 * child classes.
	 *
	 * @param CommandInterface $cmd
	 * @param RequestInterface $request
	 *
	 * @return Promise\PromiseInterface
	 */
	public function __invoke( CommandInterface $cmd, RequestInterface $request ) {
		$handler   = $this->nextHandler;
		$eventData = null;
		$enabled   = $this->isEnabled();

		if ( $enabled ) {
			$cmd['@http']['collect_stats'] = true;
			$eventData                     = $this->populateRequestEventData(
				$cmd,
				$request,
				$this->getNewEvent( $cmd, $request )
			);
		}

		$g = function ( $value ) use ( $eventData, $enabled ) {
			if ( $enabled ) {
				$eventData = $this->populateResultEventData(
					$value,
					$eventData
				);
				$this->sendEventData( $eventData );

				if ( $value instanceof MonitoringEventsInterface ) {
					$value->appendMonitoringEvent( $eventData );
				}
			}
			if ( $value instanceof Exception || $value instanceof Throwable ) {
				return Promise\rejection_for( $value );
			}

			return $value;
		};

		return Promise\promise_for( $handler( $cmd, $request ) )->then( $g, $g );
	}

	/**
	 * Returns enabled flag from options, unwrapping options if necessary.
	 *
	 * @return bool
	 */
	private function isEnabled() {
		return $this->unwrappedOptions()->isEnabled();
	}

	/**
	 * Unwraps options, if needed, and returns them.
	 *
	 * @return ConfigurationInterface
	 */
	private function unwrappedOptions() {
		if ( ! ( $this->options instanceof ConfigurationInterface ) ) {
			try {
				$this->options = ConfigurationProvider::unwrap( $this->options );
			} catch ( Exception $e ) {
				// Errors unwrapping CSM config defaults to disabling it
				$this->options = new Configuration(
					false,
					ConfigurationProvider::DEFAULT_HOST,
					ConfigurationProvider::DEFAULT_PORT
				);
			}
		}

		return $this->options;
	}

	/**
	 * Returns $eventData array with information from the request and command.
	 *
	 * @param CommandInterface $cmd
	 * @param RequestInterface $request
	 * @param array $event
	 *
	 * @return array
	 */
	protected function populateRequestEventData(
		CommandInterface $cmd,
		RequestInterface $request,
		array $event
	) {
		$dataFormat = static::getRequestData( $request );
		foreach ( $dataFormat as $eventKey => $value ) {
			if ( $value !== null ) {
				$event[ $eventKey ] = $value;
			}
		}

		return $event;
	}

	private function getNewEvent(
		CommandInterface $cmd,
		RequestInterface $request
	) {
		$event = [
			'Api'       => $cmd->getName(),
			'ClientId'  => $this->getClientId(),
			'Region'    => $this->getRegion(),
			'Service'   => $this->getService(),
			'Timestamp' => (int) floor( microtime( true ) * 1000 ),
			'UserAgent' => substr(
				$request->getHeaderLine( 'User-Agent' ) . ' ' . default_user_agent(),
				0,
				256
			),
			'Version'   => 1
		];

		return $event;
	}

	private function getClientId() {
		return $this->unwrappedOptions()->getClientId();
	}

	private function getRegion() {
		return $this->region;
	}

	private function getService() {
		return $this->service;
	}

	/**
	 * Returns $eventData array with information from the response, including
	 * the calculation for attempt latency.
	 *
	 * @param ResultInterface|Exception $result
	 * @param array $event
	 *
	 * @return array
	 */
	protected function populateResultEventData(
		$result,
		array $event
	) {
		$dataFormat = static::getResponseData( $result );
		foreach ( $dataFormat as $eventKey => $value ) {
			if ( $value !== null ) {
				$event[ $eventKey ] = $value;
			}
		}

		return $event;
	}

	/**
	 * Sends formatted monitoring event data via the UDP socket connection to
	 * the CSM agent endpoint.
	 *
	 * @param array $eventData
	 *
	 * @return int
	 */
	private function sendEventData( array $eventData ) {
		$socket   = $this->prepareSocket();
		$datagram = json_encode( $eventData );
		$result   = socket_write( $socket, $datagram, strlen( $datagram ) );
		if ( $result === false ) {
			$this->prepareSocket( true );
		}

		return $result;
	}

	/**
	 * Creates a UDP socket resource and stores it with the class, or retrieves
	 * it if already instantiated and connected. Handles error-checking and
	 * re-connecting if necessary. If $forceNewConnection is set to true, a new
	 * socket will be created.
	 *
	 * @param bool $forceNewConnection
	 *
	 * @return Resource
	 */
	private function prepareSocket( $forceNewConnection = false ) {
		if ( ! is_resource( self::$socket )
		     || $forceNewConnection
		     || socket_last_error( self::$socket )
		) {
			self::$socket = socket_create( AF_INET, SOCK_DGRAM, SOL_UDP );
			socket_clear_error( self::$socket );
			socket_connect( self::$socket, $this->getHost(), $this->getPort() );
		}

		return self::$socket;
	}

	private function getHost() {
		return $this->unwrappedOptions()->getHost();
	}

	private function getPort() {
		return $this->unwrappedOptions()->getPort();
	}
}