<?php

namespace SHServ\Tools\DeviceAPI;

class Base {
	private string $ip_address;
	private string $base_url;
	private ?string $token;
	private float $connect_timeout;
	private float $timeout;
	private $on_success_callback;
	private const MAX_RETRIES = 3;
	private const RETRY_BACKOFF_MS = 100;

	/**
	 * @param string      $ip_address IP устройства (без протокола и слеша в конце)
	 * @param string|null $token      Токен авторизации (может быть null)
	 */
	public function __construct(string $ip_address, ?string $token = null) {
		$this->ip_address = $ip_address;
		$this->base_url   = rtrim('http://' . $ip_address, '/');
		$this->token      = $token;
		$this->connect_timeout = FCONF['device_api_connect_timeout'] ?? 1;
		$this->timeout         = FCONF['device_api_timeout'] ?? 5;
	}

	/**
	 * Локально обновить токен (без запроса к устройству).
	 */
	public function set_local_token(string $token): void {
		$this->token = $token;
	}

	/**
	 * Получить текущий сохранённый токен.
	 */
	public function get_local_token(): ?string {
		return $this->token;
	}

	/**
	 * Установить callback, который вызывается после успешного HTTP-ответа от устройства.
	 */
	public function set_on_success_callback(callable $callback): void {
		$this->on_success_callback = $callback;
	}

	/**
	 * GET /about (доступен всегда, без токена).
	 */
	public function get_about(): array {
		return $this->request('GET', '/about');
	}

	/**
	 * GET /status
	 *
	 * В режиме normal требует токен, в setup может не требоваться.
	 * Токен отправляем, если он есть, но жёстко не требуем.
	 */
	public function get_status(): array {
		$resp = $this->request('GET', '/status');

		if (($resp["http_code"] ?? 0) !== 200 || !is_array($resp["data"] ?? null)) {
			return [];
		}

		return $resp["data"];
	}

	/**
	 * POST /action
	 *
	 * В normal-режиме требует токен.
	 *
	 * @param string $action
	 * @param array  $params
	 */
	public function post_action(string $action, array $params = []): array {
		$body = [
			'action' => $action,
			'params' => (object)$params, // чтобы пустой массив ушёл как {}
		];

		return $this->request('POST', '/action', $body, true);
	}

	/**
	 * POST /set_token
	 *
	 * В setup-режиме не требует токен, в normal — требует.
	 * Мы отправляем токен, если он есть, но не делаем жёсткую проверку.
	 * При успешном ответе можно локально обновить токен клиента.
	 */
	public function remote_set_token(string $token): array {
		$response = $this->request('POST', '/set_token', [
			'token' => $token,
		]);

		if (($response['http_code'] ?? 0) === 200 && ($response['data']['status'] ?? null) === 'ok') {
			$this->token = $token;
		}

		return $response;
	}

	/**
	 * POST /reboot
	 *
	 * Требует токен в normal-режиме.
	 */
	public function reboot(): array {
		return $this->request('POST', '/reboot', (object)[], true);
	}

	/**
	 * POST /reset
	 *
	 * Требует токен в normal-режиме.
	 */
	public function reset(): array {
		return $this->request('POST', '/reset', (object)[], true);
	}

	/**
	 * POST /update
	 *
	 * Отправляет бинарный файл прошивки на устройство через multipart/form-data.
	 * Требует токен в normal-режиме (хотя endpoint сейчас открыт, токен отправляется если есть).
	 *
	 * @param string $binPath Абсолютный путь к .bin файлу.
	 */
	public function updateFirmware(string $binPath): array {
		$url = $this->base_url . '/update';
		$request_start = microtime(true);

		logging() -> debug('php:DeviceAPI', 'Firmware update request', [
			'url'    => $url,
			'file'   => basename($binPath),
			'size_kb'=> file_exists($binPath) ? round(filesize($binPath) / 1024, 2) : null,
		]);

		$ch = curl_init();
		if ($ch === false) {
			throw new \RuntimeException('Failed to initialize cURL');
		}

		$file = new \CURLFile($binPath, 'application/octet-stream', basename($binPath));
		$postFields = ['firmware' => $file];

		$headers = ['Accept: text/plain'];
		if ($this->token !== null) {
			$headers[] = 'Authorization: Bearer ' . $this->token;
		}

		curl_setopt_array($ch, [
			CURLOPT_URL            => $url,
			CURLOPT_POST           => true,
			CURLOPT_POSTFIELDS     => $postFields,
			CURLOPT_RETURNTRANSFER => true,
			CURLOPT_HEADER         => true,
			CURLOPT_CONNECTTIMEOUT => $this->connect_timeout,
			CURLOPT_TIMEOUT        => 60,
			CURLOPT_HTTPHEADER     => $headers,
		]);

		$raw = curl_exec($ch);
		$err = $raw === false ? curl_error($ch) : null;
		$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
		$httpCode   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
		curl_close($ch);

		if ($raw === false) {
			return [
				'http_code' => 0,
				'headers'   => [],
				'raw'       => null,
				'data'      => null,
				'error'     => $err ?? 'Unknown cURL error',
			];
		}

		$rawBody = substr($raw, $headerSize);

		logging() -> debug('php:DeviceAPI', 'Firmware update response', [
			'url'         => $url,
			'http_code'   => (int) $httpCode,
			'duration_ms' => round((microtime(true) - $request_start) * 1000, 3),
			'error'       => $err,
			'raw_preview' => $rawBody !== '' ? substr($rawBody, 0, 2048) : null,
		]);

		return [
			'http_code' => (int) $httpCode,
			'headers'   => [],
			'raw'       => $rawBody,
			'data'      => null,
			'error'     => null,
		];
	}

	/**
	 * POST /set_device_name
	 *
	 * Требует токен в normal-режиме.
	 */
	public function set_device_name(string $device_name): array {
		return $this->request('POST', '/set_device_name', [
			'device_name' => $device_name,
		], true);
	}

	/**
	 * Базовый HTTP-запрос к устройству.
	 *
	 * @param string     $method        GET|POST
	 * @param string     $path          Например, '/about'
	 * @param array|object|null $body   Тело запроса для POST
	 * @param bool       $require_token Обязателен ли токен для этого вызова
	 *
	 * @return array{
	 *   http_code:int,
	 *   headers:array<string,string>,
	 *   raw:string|null,
	 *   data:array<string,mixed>|null,
	 *   error:string|null
	 * }
	 */
	private function request(string $method, string $path, $body = null, bool $require_token = false): array {
		if ($require_token && $this->token === null) {
			throw new \LogicException('Token is required for this endpoint but not set in DeviceApiClient');
		}

		$url = $this->base_url . $path;
		$request_start = microtime(true);

		logging() -> debug('php:DeviceAPI', 'Device request', [
			'method'        => $method,
			'url'           => $url,
			'require_token' => $require_token,
		]);

		$ch = curl_init();
		if ($ch === false) {
			throw new \RuntimeException('Failed to initialize cURL');
		}

		$headers = [
			'Accept: application/json',
		];

		if ($this->token !== null) {
			$headers[] = 'Authorization: Bearer ' . $this->token;
		}

		if (strtoupper($method) === 'POST') {
			curl_setopt($ch, CURLOPT_POST, true);

			if ($body !== null) {
				$json_body = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
				if ($json_body === false) {
					curl_close($ch);
					throw new \RuntimeException('Failed to encode JSON body: ' . json_last_error_msg());
				}
				curl_setopt($ch, CURLOPT_POSTFIELDS, $json_body);
				$headers[] = 'Content-Type: application/json';
			}
		} else {
			curl_setopt($ch, CURLOPT_HTTPGET, true);
		}

		curl_setopt_array($ch, [
			CURLOPT_URL            => $url,
			CURLOPT_RETURNTRANSFER => true,
			CURLOPT_HEADER         => true,
			CURLOPT_CONNECTTIMEOUT => $this->connect_timeout,
			CURLOPT_TIMEOUT        => $this->timeout,
			CURLOPT_HTTPHEADER     => $headers,
		]);

		$attempt = 0;
		$raw_response = false;
		$error_message = null;

		while ($attempt < self::MAX_RETRIES) {
			[$raw_response, $error_message] = $this->executeCurl($ch);

			if ($raw_response !== false) {
				break;
			}

			$attempt++;

			if ($attempt >= self::MAX_RETRIES) {
				break;
			}

			$backoff = self::RETRY_BACKOFF_MS * (2 ** ($attempt - 1));
			usleep($backoff * 1000);
			curl_reset($ch);

			curl_setopt_array($ch, [
				CURLOPT_URL            => $url,
				CURLOPT_RETURNTRANSFER => true,
				CURLOPT_HEADER         => true,
				CURLOPT_CONNECTTIMEOUT => $this->connect_timeout,
				CURLOPT_TIMEOUT        => $this->timeout,
				CURLOPT_HTTPHEADER     => $headers,
			]);

			if (strtoupper($method) === 'POST') {
				curl_setopt($ch, CURLOPT_POST, true);
				if ($body !== null) {
					curl_setopt($ch, CURLOPT_POSTFIELDS, $json_body);
				}
			} else {
				curl_setopt($ch, CURLOPT_HTTPGET, true);
			}
		}

		if ($raw_response === false) {
			curl_close($ch);

			$err = $error_message ?? 'Unknown cURL error';
			logging() -> error('php:DeviceAPI', 'Device request failed', [
				'url'         => $url,
				'method'      => $method,
				'error'       => $err,
				'duration_ms' => round((microtime(true) - $request_start) * 1000, 3),
			]);

			return [
				'http_code' => 0,
				'headers'   => [],
				'raw'       => null,
				'data'      => null,
				'error'     => $err,
			];
		}

		$header_size = $this->getCurlInfo($ch, CURLINFO_HEADER_SIZE);
		$http_code   = $this->getCurlInfo($ch, CURLINFO_HTTP_CODE);

		curl_close($ch);

		if ($http_code > 0 && $this->on_success_callback !== null) {
			($this->on_success_callback)();
		}

		$raw_headers = substr($raw_response, 0, $header_size);
		$raw_body    = substr($raw_response, $header_size);

		$parsed_headers = $this->parse_headers($raw_headers);

		$decoded = null;
		$error   = null;

		if ($raw_body !== '') {
			$decoded = json_decode($raw_body, true);
			if (!is_array($decoded)) {
				$error = 'Invalid JSON response from device';
				$decoded = null;
			}
		}

		logging() -> debug('php:DeviceAPI', 'Device response', [
			'url'         => $url,
			'method'      => $method,
			'http_code'   => (int) $http_code,
			'duration_ms' => round((microtime(true) - $request_start) * 1000, 3),
			'error'       => $error,
			'raw_preview' => $raw_body !== '' ? substr($raw_body, 0, 2048) : null,
		]);

		return [
			'http_code' => (int)$http_code,
			'headers'   => $parsed_headers,
			'raw'       => $raw_body,
			'data'      => $decoded,
			'error'     => $error,
		];
	}

	/**
	 * Выполнить curl-запрос и вернуть результат.
	 *
	 * @param \CurlHandle $ch
	 * @return array{0: string|false, 1: string|null}
	 */
	protected function executeCurl($ch): array {
		$raw = curl_exec($ch);
		$err = $raw === false ? curl_error($ch) : null;
		return [$raw, $err];
	}

	/**
	 * Получить информацию из cURL handle.
	 *
	 * @param \CurlHandle $ch
	 * @param int $option
	 * @return mixed
	 */
	protected function getCurlInfo($ch, int $option) {
		return curl_getinfo($ch, $option);
	}

	/**
	 * Простейший парсер HTTP-заголовков.
	 *
	 * @param string $raw_headers
	 *
	 * @return array<string,string>
	 */
	private function parse_headers(string $raw_headers): array
	{
		$headers = [];

		$lines = preg_split("/\r\n|\n|\r/", trim($raw_headers));
		if (!is_array($lines)) {
			return $headers;
		}

		foreach ($lines as $line) {
			if (strpos($line, ':') === false) {
				continue;
			}

			[$name, $value] = explode(':', $line, 2);
			$name  = trim($name);
			$value = trim($value);

			if ($name !== '') {
				$headers[$name] = $value;
			}
		}

		return $headers;
	}
}
