<?php

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

require_once __DIR__ . '/Api_v2_resource.php';

/**
 * API v2 - QSOs resource (reference implementation)
 *
 * Demonstrates the full CRUD pattern every other v2 resource should follow.
 * It reuses the existing Logbook_model / Stations data layer rather than
 * reimplementing QSO logic:
 *   - reads via Logbook_model::get_qso() (trusted, after our own ownership check)
 *   - lists via Logbook_model::get_qsos()
 *   - creates via Logbook_model::import_bulk() (the same path the v1 API uses)
 *   - updates via Logbook_model::update_qso_columns() (PATCH)
 *   - deletes via Logbook_model::delete() (handles OQRS/QSL/eQSL + cache)
 *
 * PATCH updates only the fields present in the body; anything omitted keeps its
 * stored value. There is deliberately no PUT: Wavelog is the source of truth, so
 * an API client should never be able to blank fields it simply did not know
 * about - a client wanting a full overwrite can send every field explicitly.
 * PATCH touches only the field whitelist in editable_fields(): QSL/LoTW/eQSL
 * bookkeeping, DXCC/country recalculation and the MY_* station refs are
 * deliberately out of scope here.
 *
 * Unit note: the JSON API speaks Hz throughout - POST (single and bulk), PATCH
 * and GET all take and return freq/freq_rx in Hz. Unit suffixes are accepted on
 * input (e.g. "7.0475M" = 7047500 Hz, see parse_frequency()). Only an ADIF
 * payload (import_type=adif) is read as MHz, because that is what the ADIF
 * standard prescribes; body_to_record() converts JSON Hz to MHz internally
 * because the shared import pipeline is ADIF-based.
 *
 * Ownership in v2 is enforced against the token's user_id (not the web
 * session): we resolve the token owner's station ids and reject anything else.
 *
 * Route:  /api/v2/qso
 * Scope:  qso:read / qso:write / qso:delete
 */
class Qso_resource extends Api_v2_resource {

	/** Token scope of this resource (see Api_v2_resource::required_scope()). */
	protected $scope = 'qso';

	/** Registry labels for this resource's scopes (see scope_definitions()). */
	protected static function scope_labels() {
		return [
			'read'   => __('Read QSOs'),
			'write'  => __('Create and update QSOs'),
			'delete' => __('Delete QSOs'),
		];
	}

	/**
	 * GET /api/v2/qso
	 * Filtered list of the token owner's QSOs. The same filtered result set is
	 * rendered as JSON (default) or as ADIF via ?format=adif — the data is fetched
	 * once and only the rendering differs.
	 *
	 * Filters (all optional):
	 *   ?station_id= comma-separated, ownership-checked; default: all owned
	 *   ?callsign=   exact match on the worked callsign (e.g. HB9HIL)
	 *   ?band=       single band filter (e.g. 20m or SAT)
	 *   ?mode=       single mode/submode filter (e.g. SSB or FT8)
	 *   ?qsl_filter= comma list of lotw|qsl|eqsl|clublog (OR-combined)
	 *   ?since_id=   only QSOs with a primary key greater than this (default 0)
	 *   ?qso_since=  YYYY-MM-DD, oldest QSO date to include (whole day)
	 *   ?qso_until=  YYYY-MM-DD, newest QSO date to include (whole day)
	 * Pagination: ?page= / ?per_page= (max 5000; default 50 for JSON, 1000 for
	 *             ADIF), or ?limit= as a shortcut for the newest N QSOs (overrides
	 *             page/per_page).
	 * Rendering:  ?format=json (default) | adif.
	 */
	public function index() {
		$format = strtolower(trim((string) $this->param('format', 'json')));
		if ($format !== 'json' && $format !== 'adif') {
			throw new Api_v2_exception(
				'validation_error',
				'Unknown format "' . $format . '". Allowed: json, adif',
				400,
				['allowed' => ['json', 'adif']]
			);
		}

		$this->CI->load->model('logbook_model');

		// One upper bound for both formats: a JSON row (format_qso) is a trimmed
		// subset of fields and thus no heavier than an ADIF line, so there is no
		// reason to cap JSON lower. Only the default page size differs — small for
		// JSON browsing, larger for ADIF bulk sync.
		$is_adif = ($format === 'adif');
		$max_per_page = 5000;
		$page        = $this->pagination($is_adif ? 1000 : 50, $max_per_page);
		$station_ids = $this->resolve_station_ids();
		$band        = $this->normalize_band($this->param('band'));
		$mode        = $this->normalize_mode($this->param('mode'));
		$callsign    = $this->normalize_callsign($this->param('callsign'));
		$qsl_filter  = $this->parse_qsl_filter();
		$since_id    = $this->parse_since_id();
		$qso_since   = $this->parse_date('qso_since');
		$qso_until   = $this->parse_date('qso_until');

		// ADIF sync needs ascending-by-id order (so lastfetchedid advances); the
		// JSON browse list is newest-first.
		$order = $is_adif ? 'id_asc' : 'time_desc';

		// `limit` is a shortcut for "the newest N QSOs" (e.g. limit=1 => the last
		// QSO). It overrides page/per_page: page 1, newest-first, capped like
		// per_page. Handy for a quick "what did I work last?" query.
		$limit = $this->param('limit');
		if ($limit !== null && $limit !== '') {
			if (!is_numeric($limit) || (int) $limit < 1) {
				throw new Api_v2_exception('validation_error', 'limit must be a positive integer', 400);
			}
			$page['page']     = 1;
			$page['offset']   = 0;
			$page['per_page'] = min((int) $limit, $max_per_page);
			$order = 'time_desc';
		}

		if (empty($station_ids)) {
			$this->respond_qsos($format, [], $page, 0, $since_id);
			return;
		}

		// A club member below officer level only ever sees its own QSOs - in the
		// JSON list and the ADIF export alike, since both run the same query.
		$operator = $this->is_restricted_club_member() ? (string) $this->operator_callsign() : '';

		$filters = [
			'station_ids' => $station_ids,
			'callsign'    => $callsign,
			'band'        => $band,
			'mode'        => $mode,
			'qsl_filter'  => $qsl_filter,
			'since_id'    => $since_id,
			'qso_since'   => $qso_since,
			'qso_until'   => $qso_until,
			'operator'    => $operator,
		];

		// Total across all pages for the same filter, so a client can find the
		// last page without probing for an empty response.
		$total = $this->CI->logbook_model->count_qsos_filtered($filters);

		$query = $this->CI->logbook_model->get_qsos_filtered(
			$filters, $order, $page['per_page'], $page['offset']
		);

		$rows = is_object($query) ? $query->result() : [];
		$this->respond_qsos($format, $rows, $page, $total, $since_id);
	}

	/**
	 * Render the fetched QSO rows in the requested format, with pagination meta.
	 * JSON returns the QSO objects; ADIF returns { exported, lastfetchedid, adif }
	 * built from the same rows via AdifHelper.
	 */
	protected function respond_qsos($format, $rows, $page, $total, $since_id) {
		$meta = $this->list_meta($page, count($rows), $total);

		if ($format === 'adif') {
			$this->CI->load->library('AdifHelper');
			$adif = $this->CI->adifhelper->getAdifHeader(
				$this->CI->config->item('app_name'),
				$this->CI->optionslib->get_option('version'),
				$this->CI->optionslib->get_option('adif_version')
			);
			$lastfetchedid = (int) $since_id;
			foreach ($rows as $row) {
				$adif .= $this->CI->adifhelper->getAdifLine($row);
				$lastfetchedid = max($lastfetchedid, (int) $row->COL_PRIMARY_KEY);
			}
			$this->CI->api_v2_response->respond([
				'exported'      => count($rows),
				'lastfetchedid' => $lastfetchedid,
				'adif'          => count($rows) > 0 ? $adif : null,
			], 200, $meta);
			return;
		}

		$qsos = [];
		foreach ($rows as $row) {
			$qsos[] = $this->format_qso($row);
		}
		$this->CI->api_v2_response->respond($qsos, 200, $meta);
	}

	/**
	 * Parse and validate the ?since_id= floor, or 0 when absent.
	 */
	protected function parse_since_id() {
		$raw = $this->param('since_id', 0);
		if (!is_numeric($raw)) {
			throw new Api_v2_exception('validation_error', 'since_id must be numeric', 400);
		}
		return (int) $raw;
	}

	/**
	 * Parse and validate the ?qsl_filter= query (comma list), or null when absent.
	 * Mirrors the confirmation types the QSO filter can match on (see
	 * Logbook_model::_qso_v2_filter_where()); HRDLog is upload-only and has no
	 * received column, so it is not among them.
	 */
	protected function parse_qsl_filter() {
		return $this->parse_type_list('qsl_filter', ['lotw', 'qsl', 'eqsl', 'qrz', 'clublog']);
	}

	/**
	 * GET /api/v2/qso/{id}
	 * A single QSO owned by the key holder.
	 */
	public function show($id) {
		$this->require_numeric_id($id);
		$this->CI->load->model('logbook_model');

		// 404 unless the QSO belongs to the key owner.
		if (!$this->CI->logbook_model->check_qso_is_accessible($id, $this->user_id())) {
			throw new Api_v2_exception('not_found', 'QSO not found', 404);
		}
		$this->require_own_qso($id);

		// Trusted read: ownership already verified against the key's user_id.
		$query = $this->CI->logbook_model->get_qso($id, true);
		if ($query === null || $query->num_rows() === 0) {
			throw new Api_v2_exception('not_found', 'QSO not found', 404);
		}

		$this->CI->api_v2_response->respond($this->format_qso($query->row()));
	}

	/**
	 * POST /api/v2/qso
	 * Create QSO(s). The body field `import_type` selects the payload format:
	 *   - "json" (default): a single QSO from the top-level fields, OR a bulk
	 *     import when a `qsos` array is present (each element a QSO object).
	 *     Required per QSO: call, band, mode, qso_date (YYYY-MM-DD), time_on.
	 *     `station_profile_id` (shared) is required at the top level; bulk accepts
	 *     an optional `dryrun`.
	 *   - "adif": a bulk ADIF import; the ADIF payload travels in the `adif` field,
	 *     with `station_profile_id` and an optional `dryrun` flag.
	 */
	public function create() {
		$this->require_write();
		$this->CI->load->model('logbook_model');
		$this->CI->load->model('stations');

		$body = $this->body();

		// Ownership-checked station profile is required for both import types.
		// The body-wide scalar check runs later (and not at all for the bulk
		// variant, whose "qsos" key is an array), so guard the type here before
		// the value reaches the data layer.
		$station_profile_id = $body['station_profile_id'] ?? null;
		if (!is_numeric($station_profile_id)
			|| !$this->CI->stations->check_station_against_user((int) $station_profile_id, $this->user_id())) {
			throw new Api_v2_exception('forbidden', 'station_profile_id does not belong to the API key owner', 403);
		}

		$import_type = strtolower(trim((string) ($body['import_type'] ?? 'json')));
		switch ($import_type) {
			case 'json':
				$this->create_from_json($body, $station_profile_id);
				return;
			case 'adif':
				$this->create_from_adif($body, $station_profile_id, !empty($body['dryrun']));
				return;
			default:
				throw new Api_v2_exception(
					'validation_error',
					'Unknown import_type "' . $import_type . '". Allowed: json, adif',
					400,
					['allowed' => ['json', 'adif']]
				);
		}
	}

	/**
	 * Create QSO(s) from JSON (import_type=json, the default). A "qsos" array in
	 * the body triggers a bulk import; otherwise a single QSO is created from the
	 * top-level fields.
	 */
	protected function create_from_json($body, $station_profile_id) {
		if (array_key_exists('qsos', $body)) {
			$this->create_bulk_json($body, $station_profile_id);
			return;
		}

		$this->require_scalar_fields($body);

		// Minimal required-field validation; the model handles the rest.
		$required = ['call', 'band', 'mode', 'qso_date', 'time_on'];
		$missing = [];
		foreach ($required as $field) {
			if (empty($body[$field])) {
				$missing[] = $field;
			}
		}
		if (!empty($missing)) {
			throw new Api_v2_exception(
				'validation_error',
				'Missing required field(s): ' . implode(', ', $missing),
				400,
				['missing' => $missing]
			);
		}

		// Build an ADIF-style record (lowercase keys) for the import pipeline.
		$record = $this->force_club_operator($this->body_to_record($body));

		// apicall = true and skipStationCheck = true: we already verified the
		// station belongs to the key owner above, so skip the session-based check.
		$result = $this->CI->logbook_model->import_bulk(
			[$record],
			$station_profile_id,
			true,   // skipDuplicate
			false, false, false, false, false, false, false,
			false,  // skipexport
			false,  // operatorName
			true,   // apicall
			true    // skipStationCheck
		);

		if (($result['qsocount'] ?? 0) < 1) {
			throw new Api_v2_exception(
				'validation_error',
				trim(strip_tags($result['errormessage'] ?? 'QSO could not be created')),
				400,
				$result['structured_errors'] ?? null
			);
		}

		// Resolve the freshly inserted QSO by primary key for the response.
		$new_id = $result['inserted_id'] ?? null;
		$created = null;
		if ($new_id) {
			$query = $this->CI->logbook_model->get_qso($new_id, true);
			if ($query !== null && $query->num_rows() > 0) {
				$created = $this->format_qso($query->row());
			}
		}

		$headers = $new_id ? ['Location' => base_url('index.php/api/v2/qso/' . $new_id)] : [];
		$this->CI->api_v2_response->respond($created ?? ['id' => $new_id], 201, null, $headers);
	}

	/**
	 * Bulk-create QSOs from a JSON array (import_type=json with a "qsos" array).
	 * Each element is a QSO object with the same fields as a single create; they
	 * are all imported into the given, ownership-checked station_profile_id. An
	 * optional top-level "dryrun" validates without importing.
	 *
	 * @param array $body               Decoded request body (must contain "qsos").
	 * @param int   $station_profile_id Ownership-checked target station.
	 */
	protected function create_bulk_json($body, $station_profile_id) {
		$qsos = $body['qsos'];
		// Must be a non-empty JSON array (a list, not an object).
		if (!is_array($qsos) || empty($qsos) || array_keys($qsos) !== range(0, count($qsos) - 1)) {
			throw new Api_v2_exception('validation_error', '"qsos" must be a non-empty array of QSO objects', 400);
		}

		$required = ['call', 'band', 'mode', 'qso_date', 'time_on'];
		$records = [];
		foreach ($qsos as $i => $qso) {
			if (!is_array($qso)) {
				throw new Api_v2_exception('validation_error', 'qsos[' . $i . '] must be an object', 400);
			}
			$this->require_scalar_fields($qso);

			$missing = [];
			foreach ($required as $field) {
				if (empty($qso[$field])) {
					$missing[] = $field;
				}
			}
			if (!empty($missing)) {
				throw new Api_v2_exception(
					'validation_error',
					'qsos[' . $i . '] missing required field(s): ' . implode(', ', $missing),
					400,
					['index' => $i, 'missing' => $missing]
				);
			}

			$records[] = $this->force_club_operator($this->body_to_record($qso));
		}

		if (!empty($body['dryrun'])) {
			$this->CI->api_v2_response->respond(['dryrun' => true, 'parsed' => count($records)], 200);
			return;
		}

		$result = $this->CI->logbook_model->import_bulk(
			$records,
			$station_profile_id,
			true,   // skipDuplicate
			false, false, false, false, false, false, false,
			false,  // skipexport
			false,  // operatorName
			true,   // apicall
			true    // skipStationCheck (ownership already verified)
		);

		$this->respond_bulk_import($result, count($records));
	}

	/**
	 * Emit the response for a bulk import (ADIF or JSON multi-QSO): a summary of
	 * parsed / imported / skipped counts plus any messages. Throws 400 when
	 * nothing was imported and only hard errors occurred.
	 *
	 * @param array $result Return value of Logbook_model::import_bulk().
	 * @param int   $parsed Number of records handed to the import.
	 */
	protected function respond_bulk_import($result, $parsed) {
		$imported = (int) ($result['qsocount'] ?? 0);
		$structured = $result['structured_errors'] ?? ['critical' => [], 'validation' => [], 'duplicate' => []];
		$skipped = count($structured['duplicate'] ?? []);
		$hard_errors = array_merge($structured['critical'] ?? [], $structured['validation'] ?? []);

		// Nothing imported and only hard errors -> the whole batch failed.
		if ($imported === 0 && !empty($hard_errors)) {
			throw new Api_v2_exception(
				'validation_error',
				trim(strip_tags($result['errormessage'] ?? 'Import failed')),
				400,
				$structured
			);
		}

		$this->CI->api_v2_response->respond([
			'parsed'   => $parsed,
			'imported' => $imported,
			'skipped'  => $skipped,
			'messages' => array_values(array_map(function ($m) { return trim(strip_tags($m)); }, $hard_errors)),
		], 201);
	}

	/**
	 * Bulk-import QSOs from an ADIF string (import_type=adif).
	 *
	 * Reuses the same parse/import pipeline as the v1 API: adif_parser feeds
	 * records into Logbook_model::import_bulk(). Clubstation operator resolution
	 * mirrors v1 — when a club member's token is used, the operator is forced to
	 * the token creator rather than the club callsign.
	 *
	 * @param array $body               Decoded request body.
	 * @param int   $station_profile_id Ownership-checked target station.
	 * @param bool  $dryrun             Parse only, import nothing.
	 */
	protected function create_from_adif($body, $station_profile_id, $dryrun) {
		$adif = $body['adif'] ?? '';
		if (!is_string($adif) || trim($adif) === '') {
			throw new Api_v2_exception('validation_error', 'import_type=adif requires a non-empty "adif" string', 400);
		}

		if (!$this->CI->load->is_loaded('adif_parser')) {
			$this->CI->load->library('adif_parser');
		}
		if (!$this->CI->load->is_loaded('Qra')) {
			$this->CI->load->library('Qra');
		}

		$profile = $this->CI->stations->profile_clean($station_profile_id);
		$mygrid = $profile->station_gridsquare ?? '';

		// Collapse whitespace right after <eor> so the parser is not tripped up
		// by pretty-printed ADIF, matching the v1 endpoint.
		$adif = preg_replace('#<([eE][oO][rR])>[\r\n\t]+#', '<$1>', $adif);
		$this->CI->adif_parser->feed($adif);

		$records = [];
		$parsed = 0;
		while ($record = $this->CI->adif_parser->get_record()) {
			if (!isset($record['call']) || trim($record['call']) === '') {
				continue;
			}
			if (count($record) === 0) {
				break;
			}

			// Normalise slashed zeros in the callsign fields.
			$record['call'] = str_replace('Ø', '0', $record['call']);
			foreach (['operator', 'station_callsign', 'owner_callsign'] as $f) {
				if (($record[$f] ?? '') !== '') {
					$record[$f] = str_replace('Ø', '0', $record[$f]);
				}
			}

			$record = $this->force_club_operator($record);

			// Fill the distance from the station's own grid when possible.
			if (array_key_exists('gridsquare', $record) && $mygrid !== ''
				&& ($record['gridsquare'] ?? '') !== '' && !array_key_exists('distance', $record)) {
				$record['distance'] = $this->CI->qra->distance($mygrid, $record['gridsquare'], 'K');
			}

			$records[] = $record;
			$parsed++;
		}

		if ($dryrun) {
			$this->CI->api_v2_response->respond(['dryrun' => true, 'parsed' => $parsed], 200);
			return;
		}

		if (empty($records)) {
			throw new Api_v2_exception('validation_error', 'No valid QSO records found in ADIF', 400);
		}

		$result = $this->CI->logbook_model->import_bulk(
			$records,
			$station_profile_id,
			true,   // skipDuplicate
			false, false, false, false, false, false, false,
			true,   // skipexport
			false,  // operatorName
			true,   // apicall
			true    // skipStationCheck (ownership already verified above)
		);

		$this->respond_bulk_import($result, $parsed);
	}

	/**
	 * PATCH /api/v2/qso/{id}
	 * Partial update: only the fields present in the body are changed.
	 */
	public function update($id) {
		$this->apply_update($id);
	}

	/**
	 * DELETE /api/v2/qso/{id}
	 */
	public function delete($id) {
		$this->require_delete();
		$this->require_numeric_id($id);
		$this->CI->load->model('logbook_model');

		// 404 unless the QSO belongs to the key owner.
		if (!$this->CI->logbook_model->check_qso_is_accessible($id, $this->user_id())) {
			throw new Api_v2_exception('not_found', 'QSO not found', 404);
		}
		$this->require_own_qso($id);

		// The model handles the full teardown (OQRS, QSL/eQSL images, cache)
		// and re-checks ownership against the same user_id.
		$this->CI->logbook_model->delete($id, $this->user_id());

		$this->CI->api_v2_response->no_content();
	}

	// --- Internal helpers --------------------------------------------------

	/**
	 * Resolve the operator of an inbound QSO record for clubstation tokens.
	 *
	 * A club token logs into the clubstation's logbook, but the QSO belongs to
	 * the member who made it - COL_OPERATOR must therefore carry the member's
	 * own callsign, not the shared club call. Two rules, mirroring the v1 API:
	 *
	 *  - Any club token: an absent operator, or one that merely repeats the club
	 *    callsign, is filled in with the member's callsign.
	 *  - Below officer level (3 and 6): the operator is overwritten
	 *    unconditionally. A member must not be able to log under someone else's
	 *    callsign by simply naming them in the payload.
	 *
	 * Applied to every create path (single JSON, bulk JSON and ADIF) so the
	 * payload format cannot decide whether the rule holds.
	 *
	 * @param array $record ADIF-style record (lowercase keys).
	 * @return array The record with the operator resolved.
	 */
	protected function force_club_operator($record) {
		// null for a personal token, and for every token when the clubstation
		// feature is off (resolve_club_context() gates on it).
		$operator = $this->operator_callsign();
		if ($operator === null) {
			return $record;
		}

		$recorded = $record['operator'] ?? '';
		if ($recorded === '' || $recorded == ($record['station_callsign'] ?? '')) {
			$record['operator'] = $operator;
		}

		if ($this->is_restricted_club_member()) {
			$record['operator'] = $operator;
		}

		return $record;
	}

	/**
	 * Guard a per-QSO write for club members below officer level: they may only
	 * touch QSOs they made themselves.
	 *
	 * check_qso_is_accessible() only proves the QSO sits in a station location
	 * of the token owner - and for a club token the owner *is* the clubstation,
	 * so every QSO in the club log passes it. The operator is what separates one
	 * member's QSOs from another's, which is exactly what
	 * clubaccess_filter_qso_ids() checks for the batch flows in the web UI.
	 *
	 * A foreign QSO is reported as 404 rather than 403, matching how the rest of
	 * this resource treats a QSO the token may not see: not confirming that it
	 * exists at all.
	 *
	 * @param int $id QSO primary key, already known to be accessible.
	 * @throws Api_v2_exception 404 when the QSO belongs to another operator.
	 */
	protected function require_own_qso($id) {
		if (!$this->is_restricted_club_member()) {
			return;
		}

		$query = $this->CI->logbook_model->get_qso($id, true);
		$row = ($query !== null && $query->num_rows() > 0) ? $query->row() : null;
		// Compared case-insensitively: the import pipeline uppercases the
		// operator, but older rows may hold it as typed.
		if ($row === null || strcasecmp((string) $row->COL_OPERATOR, (string) $this->operator_callsign()) !== 0) {
			throw new Api_v2_exception('not_found', 'QSO not found', 404);
		}
	}

	/**
	 * PATCH implementation.
	 *
	 * @param int $id QSO primary key from the path.
	 */
	protected function apply_update($id) {
		$this->require_write();
		$this->require_numeric_id($id);
		$this->CI->load->model('logbook_model');

		// 404 unless the QSO belongs to the token owner.
		if (!$this->CI->logbook_model->check_qso_is_accessible($id, $this->user_id())) {
			throw new Api_v2_exception('not_found', 'QSO not found', 404);
		}
		$this->require_own_qso($id);

		$body = $this->body();
		$this->require_scalar_fields($body);

		$data = $this->build_update_data($body);

		// Optional move to another station location, ownership-checked.
		if (array_key_exists('station_profile_id', $body)) {
			$this->CI->load->model('stations');
			$station_profile_id = $body['station_profile_id'];
			if (!$this->CI->stations->check_station_against_user($station_profile_id, $this->user_id())) {
				throw new Api_v2_exception('forbidden', 'station_profile_id does not belong to the API token owner', 403);
			}
			$profile = $this->CI->stations->profile_clean($station_profile_id);
			$data['station_id'] = $station_profile_id;
			$data['COL_STATION_CALLSIGN'] = trim($profile->station_callsign);
		}

		if (empty($data)) {
			throw new Api_v2_exception('validation_error', 'No editable fields in request body', 400);
		}

		$this->CI->logbook_model->update_qso_columns($id, $data);

		// Respond with the fresh state of the QSO.
		$query = $this->CI->logbook_model->get_qso($id, true);
		$this->CI->api_v2_response->respond($this->format_qso($query->row()));
	}

	/**
	 * Build the COL_* update array from the JSON body. Only fields present in
	 * the body are touched; anything omitted keeps its stored value.
	 */
	protected function build_update_data($body) {
		$data = [];

		// Date/time: qso_date and time_on always travel together.
		$has_date = array_key_exists('qso_date', $body);
		$has_time = array_key_exists('time_on', $body);
		if ($has_date !== $has_time) {
			throw new Api_v2_exception('validation_error', 'qso_date and time_on must be supplied together', 400);
		}
		if ($has_date) {
			$time_on = $this->parse_datetime($body['qso_date'], $body['time_on']);
			$data['COL_TIME_ON'] = $time_on;

			// time_off defaults to time_on and is clamped to >= time_on,
			// same as the web UI edit path.
			$time_off = isset($body['time_off']) && $body['time_off'] !== ''
				? $this->parse_datetime($body['qso_date'], $body['time_off'])
				: $time_on;
			if (strtotime($time_off) < strtotime($time_on)) {
				$time_off = $time_on;
			}
			$data['COL_TIME_OFF'] = $time_off;
		}

		// Mode: split into main mode / submode like the web UI does.
		if (array_key_exists('mode', $body)) {
			$main_mode = $this->CI->logbook_model->get_main_mode_if_submode($body['mode']);
			if ($main_mode == null) {
				$data['COL_MODE'] = $body['mode'];
				$data['COL_SUBMODE'] = null;
			} else {
				$data['COL_MODE'] = $main_mode;
				$data['COL_SUBMODE'] = $body['mode'];
			}
		}

		// Frequencies are normalised to Hz.
		foreach (['freq' => 'COL_FREQ', 'freq_rx' => 'COL_FREQ_RX'] as $key => $col) {
			if (array_key_exists($key, $body)) {
				$data[$col] = $this->CI->logbook_model->parse_frequency($body[$key]);
			}
		}

		if (array_key_exists('call', $body) && !$this->CI->logbook_model->is_valid_callsign($body['call'])) {
			throw new Api_v2_exception('validation_error', 'Invalid callsign', 400, ['field' => 'call']);
		}

		// Simple whitelisted fields, only those present in the body.
		foreach ($this->editable_fields() as $key => $def) {
			list($col, $upper) = $def;
			if (array_key_exists($key, $body)) {
				$value = $body[$key];
				if ($upper && is_string($value)) {
					$value = strtoupper(trim($value));
				}
				$data[$col] = $value;
			}
		}

		return $data;
	}

	/**
	 * Editable simple fields: json key => [column, uppercase].
	 * Date/time, mode and frequencies are handled separately above.
	 */
	protected static function editable_fields() {
		return [
			'call'       => ['COL_CALL', true],
			'band'       => ['COL_BAND', false],
			'band_rx'    => ['COL_BAND_RX', false],
			'rst_sent'   => ['COL_RST_SENT', false],
			'rst_rcvd'   => ['COL_RST_RCVD', false],
			'gridsquare' => ['COL_GRIDSQUARE', true],
			'name'       => ['COL_NAME', false],
			'comment'    => ['COL_COMMENT', false],
			'notes'      => ['COL_NOTES', false],
			'qth'        => ['COL_QTH', false],
			'tx_pwr'     => ['COL_TX_PWR', false],
			'prop_mode'  => ['COL_PROP_MODE', false],
			'sat_name'   => ['COL_SAT_NAME', true],
			'sat_mode'   => ['COL_SAT_MODE', true],
			'sota_ref'   => ['COL_SOTA_REF', true],
			'pota_ref'   => ['COL_POTA_REF', true],
			'wwff_ref'   => ['COL_WWFF_REF', true],
			'iota'       => ['COL_IOTA', true],
			'sig'        => ['COL_SIG', true],
			'sig_info'   => ['COL_SIG_INFO', true],
			'darc_dok'   => ['COL_DARC_DOK', true],
			'state'      => ['COL_STATE', false],
			'cnty'       => ['COL_CNTY', false],
			'cqz'        => ['COL_CQZ', false],
			'ituz'       => ['COL_ITUZ', false],
			'qsl_via'    => ['COL_QSL_VIA', false],
			'srx'        => ['COL_SRX', false],
			'stx'        => ['COL_STX', false],
			'srx_string' => ['COL_SRX_STRING', true],
			'stx_string' => ['COL_STX_STRING', true],
		];
	}

	/**
	 * Combine an ADIF-style date (YYYY-MM-DD) and time (HHMM[SS] or HH:MM[:SS])
	 * into a datetime string, or throw a 400 on garbage input.
	 */
	protected function parse_datetime($date, $time) {
		// Normalise compact ADIF times (1200 / 120030) to colon notation.
		if (preg_match('/^\d{4}$/', $time)) {
			$time = substr($time, 0, 2) . ':' . substr($time, 2, 2);
		} elseif (preg_match('/^\d{6}$/', $time)) {
			$time = substr($time, 0, 2) . ':' . substr($time, 2, 2) . ':' . substr($time, 4, 2);
		}

		$timestamp = strtotime($date . ' ' . $time);
		if ($timestamp === false) {
			throw new Api_v2_exception('validation_error', 'Invalid qso_date/time value', 400);
		}
		return date('Y-m-d H:i:s', $timestamp);
	}

	/**
	 * Validate a path id is a positive integer, else 404.
	 */
	protected function require_numeric_id($id) {
		if (!is_numeric($id) || (int) $id < 1) {
			throw new Api_v2_exception('not_found', 'QSO not found', 404);
		}
	}

	/**
	 * Map incoming JSON fields to ADIF-style record keys understood by
	 * Logbook_model::import(). Unknown keys are passed through unchanged, so
	 * any valid ADIF field name (lowercase) works out of the box. Dates and
	 * times are normalised to the compact ADIF notation the import pipeline
	 * expects (20260610 / 1200), so ISO input (2026-06-10 / 12:00) works too.
	 */
	protected function body_to_record($body) {
		$record = [];
		foreach ($body as $key => $value) {
			if ($key === 'station_profile_id') {
				continue;
			}
			$key = strtolower($key);
			if ($key === 'freq' || $key === 'freq_rx') {
				// JSON input is Hz (same as GET and PATCH). The import pipeline
				// is ADIF-based and therefore expects MHz, so convert here -
				// this is the only place the two unit worlds meet.
				$hz = $this->CI->logbook_model->parse_frequency(is_string($value) ? $value : (string) $value);
				$record[$key] = $hz > 0 ? $this->hz_to_mhz($hz) : $value;
				continue;
			}
			if (is_string($value)) {
				if ($key === 'qso_date' || $key === 'qso_date_off') {
					$value = str_replace('-', '', $value);
				} elseif ($key === 'time_on' || $key === 'time_off') {
					$value = str_replace(':', '', $value);
				}
			}
			$record[$key] = $value;
		}
		return $record;
	}

	/**
	 * Convert a frequency in Hz to the MHz string the ADIF import expects, with
	 * up to 1 Hz resolution and no trailing zeros (e.g. 14200000 -> "14.2").
	 */
	protected function hz_to_mhz($hz) {
		return rtrim(rtrim(number_format($hz / 1000000, 6, '.', ''), '0'), '.');
	}

	/**
	 * Shape a QSO DB row into the public API representation. Kept deliberately
	 * small for the reference implementation; extend as needed.
	 */
	protected function format_qso($row) {
		$qso = [];

		foreach (self::read_only_fields() as $key => $spec) {
			$value = $row->{$spec[0]} ?? null;
			$qso[$key] = ($spec[1] === 'integer' && $value !== null) ? (int) $value : $value;
		}

		// Driven by editable_fields() on purpose: a client must be able to read
		// back every field it may write, or a read-modify-write cycle would
		// silently drop whatever GET never showed it.
		foreach (self::editable_fields() as $key => $spec) {
			$value = $row->{$spec[0]} ?? null;
			if (in_array($key, self::numeric_fields(), true)) {
				$value = ($value === null || $value === '') ? null : (int) $value;
			}
			$qso[$key] = $value;
		}

		return $qso;
	}

	/**
	 * Fields GET returns but PATCH cannot write: json key => [column, type].
	 * The identifiers plus the date/mode/frequency group, which POST derives
	 * from the ADIF payload and apply_update() handles separately.
	 */
	protected static function read_only_fields() {
		return [
			'id'         => ['COL_PRIMARY_KEY', 'integer'],
			'station_id' => ['station_id', 'integer'],
			'qso_date'   => ['COL_TIME_ON', 'string'],
			'mode'       => ['COL_MODE', 'string'],
			'submode'    => ['COL_SUBMODE', 'string'],
			'freq'       => ['COL_FREQ', 'string'],
			'freq_rx'    => ['COL_FREQ_RX', 'string'],
		];
	}

	/**
	 * Editable fields whose column holds a number; the driver hands everything
	 * back as strings, so these get cast on output.
	 */
	protected static function numeric_fields() {
		return ['cqz', 'ituz', 'srx', 'stx'];
	}
}
