<?php

if (!defined('BASEPATH')) exit('No direct script access allowed');

require_once APPPATH . 'libraries/api_v2/Api_v2_exception.php';

/**
 * Wavelog REST API v2 - central dispatcher
 *
 * A single catch-all controller for every /api/v2/... request. Unlike the v1
 * API (RPC-style, method name in the URL, key in the JSON body), v2 is a real
 * REST API:
 *
 *   - Routing:   /api/v2/<resource>[/<id>]   (resource names are singular, lowercase)
 *   - Verbs:     GET (read), POST (create), PUT/PATCH (update), DELETE (delete)
 *   - Auth:      Authorization: Bearer <token>   (X-API-Key accepted as fallback)
 *                Only the new "wl2_" tokens (table `api_token`, Api_v2_model)
 *                are accepted; legacy v1 keys are rejected. Each token carries
 *                granular scopes ("qso:read", "qso:write", ...) which are
 *                enforced per resource and verb before dispatching.
 *   - Response:  slim envelope { "data": ... } / { "error": { code, message } };
 *                the HTTP status carries the semantics.
 *
 * Adding a new resource requires no change here: drop a
 * `application/libraries/api_v2/<Resource>_resource.php` class extending
 * Api_v2_resource and it is dispatched by convention.
 *
 * @see application/config/routes.php  ($route['api/v2/(:any)'])
 * @see application/libraries/api_v2/Api_v2_resource.php
 */
class Api_v2 extends CI_Controller {

	public function __construct() {
		parent::__construct();
		$this->load->library('api_v2_response');
		$this->load->model('api_v2_model');
	}

	/**
	 * Single entry point for all /api/v2/... requests.
	 *
	 * The catch-all route forwards the path after /api/v2/ as individual URI
	 * segments, so CodeIgniter passes them as separate arguments here. We
	 * collect them all via a variadic parameter (e.g. route('qso', '42')).
	 *
	 * @param string ...$segments The path segments after /api/v2/.
	 */
	public function route(...$segments) {
		$method = $this->input->method(true);

		// Answer CORS preflight before doing any work.
		if ($method === 'OPTIONS') {
			$this->handle_preflight();
			return;
		}

		// Drop any empty segments (e.g. from a trailing slash).
		$segments = array_values(array_filter($segments, function ($s) {
			return $s !== '' && $s !== null;
		}));

		$resource = $segments[0] ?? '';
		$id       = $segments[1] ?? null;

		try {
			if (count($segments) > 2) {
				throw new Api_v2_exception('not_found', 'Unknown resource path', 404);
			}

			// Public meta endpoints: GET /api/v2 and GET /api/v2/status.
			// Neither has addressable items, so a trailing id is not a URL either.
			if ($resource === '' || $resource === 'status') {
				if ($id !== null) {
					throw new Api_v2_exception('not_found', 'Unknown resource path', 404);
				}
				if ($method !== 'GET') {
					$this->reject_method(['GET']);
				}
				$this->api_v2_response->respond([
					'name'    => 'Wavelog API',
					'status'  => 'ok',
				]);
				return;
			}

			// Everything past here requires a valid API token.
			try {
				$auth = $this->authenticate();
			} catch (Api_v2_exception $e) {
				// Throttle failed attempts by IP; a rejected token never reaches
				// the per-token limit below. Only failures count, so valid
				// clients sharing a NAT address are spared.
				$this->enforce_rate_limit('api_v2_auth', $this->input->ip_address());
				throw $e;
			}

			// Per-resource rate limiting keyed by the token id (the plaintext
			// token is never kept around after authentication).
			$this->enforce_rate_limit('api_v2_' . $resource, $auth['id']);

			$handler = $this->load_resource($resource, $auth, $method);

			// Verb support first: a verb the resource does not implement is a
			// 405, not a permission problem. Checking it after the scope check
			// would answer "insufficient_scope" for a scope that cannot exist
			// (e.g. POST on a read-only resource needs "<res>:write", which is
			// never in the registry) and hide the real reason from the client.
			$allowed = $handler->supported_methods();
			if (!in_array($method, $allowed, true)) {
				$this->reject_method($allowed);
			}

			// Scope enforcement: the resource declares its scope name, the
			// verb decides between :read and :write. null = public resource.
			$required = $handler->required_scope($method);
			if ($required !== null && !in_array($required, $auth['scopes'], true)) {
				throw new Api_v2_exception(
					'insufficient_scope',
					'Token is missing the required scope: ' . $required,
					403,
					['required_scope' => $required]
				);
			}

			$this->dispatch($handler, $method, $id);

		} catch (Api_v2_exception $e) {
			$this->api_v2_response->error(
				$e->get_error_code(),
				$e->getMessage(),
				$e->get_http_status(),
				$e->get_details()
			);
		} catch (Throwable $e) {
			log_message('error', 'API v2 unhandled: ' . $e->getMessage());
			$this->api_v2_response->error('internal_error', 'Internal server error', 500);
		}
	}

	/**
	 * Validate the Bearer/X-API-Key credential and build the auth context.
	 * Only v2 tokens ("wl2_" prefix) are accepted; scope checks happen later
	 * in route() once the target resource is known.
	 *
	 * @return array { id, user_id, created_by, scopes }
	 * @throws Api_v2_exception 401
	 */
	protected function authenticate() {
		$token = $this->extract_key();
		if ($token === null) {
			throw new Api_v2_exception('unauthorized', 'Missing API token', 401);
		}

		// Fast-fail anything that is not a v2 token (e.g. legacy v1 keys).
		if (strpos($token, Api_v2_model::TOKEN_PREFIX) !== 0) {
			throw new Api_v2_exception(
				'invalid_token',
				'API v2 requires a ' . Api_v2_model::TOKEN_PREFIX . ' token; legacy v1 API keys are not accepted',
				401
			);
		}

		$auth = $this->api_v2_model->authenticate_token($token);
		if ($auth === null) {
			throw new Api_v2_exception('invalid_token', 'Invalid or revoked API token', 401);
		}
		if ($auth['expired']) {
			throw new Api_v2_exception('token_expired', 'API token has expired', 401);
		}

		$auth = $this->resolve_club_context($auth);

		$this->api_v2_model->update_last_used($auth['id'], $auth['user_id']);

		return $auth;
	}

	/**
	 * Re-check the clubstation membership behind a club token and add the club
	 * context the resources need to enforce the permission levels.
	 *
	 * A club token belongs to the clubstation (user_id) but was issued by a
	 * member acting on its behalf (created_by). Two things follow from that:
	 *
	 *  - The membership can be withdrawn at any time and the token carries no
	 *    record of it, so without this check a removed member would keep full
	 *    access to the club logbook until the token happens to expire.
	 *  - What the member may do depends on the permission level behind that
	 *    membership (3 = member, 6 = member ADIF, 9 = officer). The web UI reads
	 *    it from the session via clubaccess_check(), which is useless here, so
	 *    the level travels in the auth context instead.
	 *
	 * Personal tokens (owner == creator) skip both lookups entirely and keep
	 * null for the club fields - they are never restricted by club rules.
	 *
	 * @param array $auth Authentication context from authenticate_token().
	 * @return array The same context plus club_permission and operator_callsign.
	 * @throws Api_v2_exception 403 once the membership is gone.
	 */
	protected function resolve_club_context($auth) {
		$auth['club_permission']   = null;
		$auth['operator_callsign'] = null;

		// Same master switch clubaccess_check() uses: with the clubstation
		// feature off there are no club rules to apply, for the API no more than
		// for the web UI.
		if (!$this->config->item('special_callsign')) {
			return $auth;
		}

		if ($auth['created_by'] === $auth['user_id']) {
			return $auth;
		}

		$this->load->model('club_model');
		// Returns 0 for a user that is no longer a member of the clubstation.
		$permission = (int) $this->club_model->get_permission_noui($auth['user_id'], $auth['created_by']);
		if ($permission < 1) {
			// An administrator manages every clubstation in the web UI
			// so he should be able to manage any club also via the API
			if ($this->user_model->is_admin($auth['created_by'])) {
				$permission = 9;
			} else {
				// The token is valid, the permission behind it is not: 403, not 401.
				throw new Api_v2_exception(
					'club_access_revoked',
					'The clubstation membership behind this token has been revoked',
					403
				);
			}
		}

		$auth['club_permission'] = $permission;

		// The acting member's own callsign: what their QSOs are logged under and
		// what the per-operator restrictions are matched against.
		$creator = $this->user_model->get_by_id($auth['created_by']);
		if ($creator !== null && $creator->num_rows() > 0) {
			$auth['operator_callsign'] = $creator->row()->user_callsign;
		}

		return $auth;
	}

	/**
	 * Read the API key from the Authorization: Bearer header, falling back to
	 * the X-API-Key header.
	 *
	 * @return string|null
	 */
	protected function extract_key() {
		$auth_header = $this->input->get_request_header('Authorization', true);
		if ($auth_header && preg_match('/^Bearer\s+(\S+)$/i', trim($auth_header), $m)) {
			return $m[1];
		}

		$api_key_header = $this->input->get_request_header('X-API-Key', true);
		if ($api_key_header) {
			return trim($api_key_header);
		}

		return null;
	}

	/**
	 * Instantiate the resource handler for the given resource name.
	 *
	 * @throws Api_v2_exception 404 when no handler exists for the resource.
	 */
	protected function load_resource($resource, $auth, $method) {
		// Resource names are singular lowercase; the class is "<Resource>_resource".
		if (!preg_match('/^[a-z][a-z0-9_]*$/', $resource)) {
			throw new Api_v2_exception('not_found', 'Unknown resource', 404);
		}

		$class = ucfirst($resource) . '_resource';
		$file  = APPPATH . 'libraries/api_v2/' . $class . '.php';

		if (!is_file($file)) {
			throw new Api_v2_exception('not_found', 'Unknown resource: ' . $resource, 404);
		}

		require_once $file;
		// The class must be a concrete subclass of Api_v2_resource. This also
		// rejects the abstract base class itself (e.g. GET /api/v2/api_v2).
		if (!class_exists($class) || !is_subclass_of($class, 'Api_v2_resource')) {
			throw new Api_v2_exception('not_found', 'Unknown resource: ' . $resource, 404);
		}

		if (!$class::is_available()) {
			throw new Api_v2_exception('not_found', 'Unknown resource: ' . $resource, 404);
		}

		$body = in_array($method, ['POST', 'PUT', 'PATCH'], true) ? $this->read_json_body() : null;

		return new $class($auth, $body);
	}

	/**
	 * Map the HTTP verb to a handler method and invoke it.
	 *
	 * @throws Api_v2_exception 405 for unsupported verbs.
	 */
	protected function dispatch($handler, $method, $id) {
		// route() has already rejected any verb this resource does not implement;
		// the base class' 405 stubs stay in place as a safety net.
		$allowed = $handler->supported_methods();

		switch ($method) {
			case 'GET':
				($id === null) ? $handler->index() : $handler->show($id);
				break;
			case 'POST':
				if ($id !== null) {
					// POST creates, it never addresses an existing item: the
					// remaining verbs are exactly the id-addressable ones.
					$this->reject_method(array_values(array_diff($allowed, ['POST'])));
				}
				$handler->create();
				break;
			case 'PUT':
				// Full replacement of the resource (idempotent).
				if ($id === null) {
					throw new Api_v2_exception('not_found', 'Missing resource id', 404);
				}
				$handler->replace($id);
				break;
			case 'PATCH':
				// Partial update of the supplied fields.
				if ($id === null) {
					throw new Api_v2_exception('not_found', 'Missing resource id', 404);
				}
				$handler->update($id);
				break;
			case 'DELETE':
				if ($id === null) {
					throw new Api_v2_exception('not_found', 'Missing resource id', 404);
				}
				$handler->delete($id);
				break;
			default:
				$this->reject_method(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
		}
	}

	/**
	 * Read and decode the JSON request body.
	 *
	 * @throws Api_v2_exception 400 on malformed JSON.
	 */
	protected function read_json_body() {
		$raw = file_get_contents('php://input');
		if ($raw === '' || $raw === false) {
			return [];
		}
		$decoded = json_decode($raw, true);
		if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
			throw new Api_v2_exception('invalid_json', 'Request body is not valid JSON', 400);
		}
		return is_array($decoded) ? $decoded : [];
	}

	/**
	 * Enforce rate limiting for the given endpoint/identifier.
	 * No-op unless `api_rate_limits` is configured.
	 *
	 * @throws Api_v2_exception 429 when the limit is exceeded.
	 */
	protected function enforce_rate_limit($endpoint, $identifier) {
		if (!$this->load->is_loaded('rate_limit')) {
			$this->load->library('rate_limit');
		}
		$result = $this->rate_limit->check($endpoint, $identifier);
		if (!$result['allowed']) {
			$this->output->set_header('Retry-After: ' . $result['retry_after']);
			throw new Api_v2_exception(
				'rate_limited',
				'Rate limit exceeded. Try again in ' . $result['retry_after'] . ' seconds.',
				429,
				['retry_after' => $result['retry_after']]
			);
		}
	}

	/**
	 * Throw a 405 with the appropriate Allow header.
	 */
	protected function reject_method($allowed) {
		$this->output->set_header('Allow: ' . implode(', ', $allowed));
		throw new Api_v2_exception('method_not_allowed', 'HTTP method not allowed', 405);
	}

	/**
	 * Answer a CORS preflight request.
	 */
	protected function handle_preflight() {
		$this->output
			->set_header('Access-Control-Allow-Origin: *')
			->set_header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS')
			->set_header('Access-Control-Allow-Headers: Authorization, Content-Type, X-API-Key')
			->set_header('Access-Control-Max-Age: 86400')
			->set_status_header(204);
	}

}
