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

/*
	This controller contains features for contesting.
	It replaces the old contesting which had no club compatibility.
*/

use Wavelog\Dxcc\Dxcc;

require_once APPPATH . '../src/Dxcc/Dxcc.php';

class Contesting extends CI_Controller {
	/**
	 * Holds QSOs created during the current sync cycle
	 */
	private $new_qsos = [];

	/**
	 * Worker availability
	 */
	private $worker_available = false;

	/**
	 * Active Station Location
	 */
	private $active_station_location = null;

	/**
	 * Cache keys
	 */
	const CACHE_KEY_SESSION_SETTINGS = 'contest_session_settings_';

	function __construct() {
		parent::__construct();

		if (!$this->user_model->authorize(2)) {
			$this->session->set_flashdata('error', __("You're not allowed to do that!"));
			redirect('dashboard');
		}

		if ($this->session->userdata('station_profile_id') ?? 0) {	// Last Station from session accessible? Take it!
			$this->active_station_location = $this->session->userdata('station_profile_id');
		} else {
			$this->active_station_location = $this->stations->find_active();
		}

		$this->load->is_loaded('Worker') ?: $this->load->library('Worker');
		$this->worker_available = $this->worker->is_enabled();

		$this->load->driver('cache', [
			'adapter'    => $this->config->item('cache_adapter')    ?? 'file',
			'backup'     => $this->config->item('cache_backup')     ?? 'file',
			'key_prefix' => $this->config->item('cache_key_prefix') ?? ''
		]);
	}

	/**
	 * Contest Management Dashboard
	 */
	public function index() {
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
		$this->load->is_loaded('form_validation') ?: $this->load->library('form_validation');

		$data['page_title'] = __("Contest Management");
		// is a custom name set, we use this one and the ADIF name in brackets, otherwise we just use the ADIF name
		$data['my_contests'] = array_map(function($row) {
			$row['display_name'] = !empty($row['custom_name'])
				? htmlspecialchars($row['custom_name']) . ' <span class="text-muted">(' . htmlspecialchars($row['contestname']) . ')</span>'
				: htmlspecialchars($row['contestname'] ?? '-');
			return $row;
		}, $this->contesting_model->get_user_contests());

		if ($this->session->userdata('user_date_format')) {
			$data['custom_date_format'] = $this->session->userdata('user_date_format');
		} else {
			$data['custom_date_format'] = $this->config->item('qso_date_format');
		}

		$footerData = [];
		$footerData['scripts'] = [
			'assets/js/sections/contesting/manager.js',
		];

		$this->load->view('interface_assets/header', $data);
		$this->load->view('contesting/manager/index');
		$this->load->view('interface_assets/footer', $footerData);
	}

	public function quickstart() {
		if (!clubaccess_check(9)) {
			$this->session->set_flashdata('error', __("Officers must set up contests."));
			redirect('contesting');
		}

		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
		$this->load->is_loaded('stations') ?: $this->load->model('stations');

		if ($this->session->userdata('user_date_format')) {
			$custom_date_format = $this->session->userdata('user_date_format');
		} else {
			$custom_date_format = $this->config->item('qso_date_format');
		}

		$contest_adif_id = 1; // Contest "Other"
		$session_start = date('Y-m-d H:i');
		$session_end = date('Y-m-d H:i', strtotime('+48 hours')); // 48 hours from now
		$station_location = $this->active_station_location;
		$session_notes = sprintf(__("Quickstart Session: %s"), date($custom_date_format . ' H:i'));

		$session_id = $this->contesting_model->create_contest_session($contest_adif_id, $session_start, $session_end, $station_location, $session_notes, true);

		redirect('contesting/logging_engine/' . $session_id);
	}

	/**
	 * CBR Export Page for contests
	 */
	public function export_cbr($contest_session_id) {
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');

		if (!$this->contesting_model->check_user_contest($contest_session_id)) {
			$this->session->set_flashdata('error', __("Contest session not found."));
			redirect('contesting');
		}

		$session_info = $this->contesting_model->get_session_info($contest_session_id);
		$cabrillo     = $this->contesting_model->get_exportformat_settings($contest_session_id, "cabrillo");
		$userinfo     = $this->user_model->get_by_id($this->session->userdata('user_id'))->row();

		$session_operators = $this->contesting_model->get_session_operators($contest_session_id);

		$effective_name = !empty($session_info['custom_name']) ? $session_info['custom_name'] : $session_info['contest_name'];
		$data['page_title']         = sprintf(__("CBR Export: %s"), $effective_name) . ($session_info['comment'] != '' ? ' - '. $session_info['comment'] : '');
		$data['session_info']       = $session_info;
		$data['cabrillo']           = $cabrillo;
		$data['qso_count']          = $this->contesting_model->get_session_qso_count($contest_session_id);
		$data['user_name']          = trim($userinfo->user_firstname . ' ' . $userinfo->user_lastname);
		$data['user_email']         = $userinfo->user_email;
		$data['session_operators']  = $session_operators;
		$data['contest_session_id'] = $contest_session_id;

		$this->load->view('interface_assets/header', $data);
		$this->load->view('contesting/manager/export_cbr');
		$this->load->view('interface_assets/footer');
	}

	/**
	 * EDI Export Page for contests
	 */
	public function export_edi($contest_session_id) {
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');

		if (!$this->contesting_model->check_user_contest($contest_session_id)) {
			$this->session->set_flashdata('error', __("Contest session not found."));
			redirect('contesting');
		}

		$session_info = $this->contesting_model->get_session_info($contest_session_id);
		$reg1test     = $this->contesting_model->get_exportformat_settings($contest_session_id, "reg1test");
		$userinfo     = $this->user_model->get_by_id($this->session->userdata('user_id'))->row();

		$session_operators = $this->contesting_model->get_session_operators($contest_session_id);
		$session_bands = $this->contesting_model->get_session_bands($contest_session_id);

		$effective_name = !empty($session_info['custom_name']) ? $session_info['custom_name'] : $session_info['contest_name'];
		$data['page_title']         = sprintf(__("Export: %s"), $effective_name) . ($session_info['comment'] != '' ? ' - '. $session_info['comment'] : '');
		$data['session_info']       = $session_info;
		$data['reg1test']           = $reg1test;
		$data['bands']				= $session_bands;
		$data['qso_count']          = $this->contesting_model->get_session_qso_count($contest_session_id);
		$data['user_name']          = trim($userinfo->user_firstname . ' ' . $userinfo->user_lastname);
		$data['user_email']         = $userinfo->user_email;
		$data['session_operators']  = $session_operators;
		$data['contest_session_id'] = $contest_session_id;

		$this->load->view('interface_assets/header', $data);
		$this->load->view('contesting/manager/export_edi');
		$this->load->view('interface_assets/footer');
	}

	/**
	 * ADIF export for a specific contest session.
	 * POST /contesting/export_adif/<id>
	 */
	public function export_adif($contest_session_id) {
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');

		if (!$this->contesting_model->check_user_contest($contest_session_id) || !clubaccess_check(6)) {
			show_404();
		}

		$session_info = $this->contesting_model->get_session_info($contest_session_id);
		$qsos         = $this->contesting_model->get_session_qsos_for_adif($contest_session_id);

		$callsign = strtoupper(str_replace('/', '-', $session_info['station_callsign'] ?? 'STATION'));
		$contest  = strtoupper($session_info['contest_adifname'] ?? 'CONTEST');
		$filename = $callsign . '-' . $contest . '-' . date('Ymd') . '.adi';

		$this->load->library('AdifHelper');

		header('Content-Type: application/octet-stream');
		header('Content-Disposition: attachment; filename="' . $filename . '"');

		echo $this->adifhelper->getAdifHeader(
			$this->config->item('app_name'),
			$this->optionslib->get_option('version'),
			$this->optionslib->get_option('adif_version')
		);

		foreach ($qsos->result() as $qso) {
			echo $this->adifhelper->getAdifLine($qso);
		}
	}

	/**
	 * Cabrillo export for a specific contest session.
	 * Saves Cabrillo category settings back to the session before streaming the file.
	 * POST /contesting/export_cabrillo/<id>
	 */
	public function export_cabrillo($contest_session_id) {
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');

		if (!$this->contesting_model->check_user_contest($contest_session_id) || !clubaccess_check(6)) {
			show_404();
		}

		$cabrillo = [
			'category_operator'    => $this->input->post('categoryoperator', true)    ?? 'SINGLE-OP',
			'category_assisted'    => $this->input->post('categoryassisted', true)    ?? 'NON-ASSISTED',
			'category_band'        => $this->input->post('categoryband', true)        ?? 'ALL',
			'category_mode'        => $this->input->post('categorymode', true)        ?? 'MIXED',
			'category_power'       => $this->input->post('categorypower', true)       ?? 'LOW',
			'category_station'     => $this->input->post('categorystation', true)     ?? 'FIXED',
			'category_transmitter' => $this->input->post('categorytransmitter', true) ?? 'ONE',
			'category_time'        => $this->input->post('categorytime', true)        ?? '',
			'category_overlay'     => $this->input->post('categoryoverlay', true)     ?? '',
			'club'                 => $this->input->post('club', true)                ?? '',
			'location'             => $this->input->post('location', true)            ?? '',
			'operators'            => $this->input->post('operators', true)           ?? '',
			'name'                 => $this->input->post('cbr_name', true)            ?? '',
			'email'                => $this->input->post('cbr_email', true)           ?? '',
			'address'              => $this->input->post('address', true)             ?? '',
			'addresscity'          => $this->input->post('addresscity', true)         ?? '',
			'addressprovince'      => $this->input->post('addressprovince', true)     ?? '',
			'addresspostalcode'    => $this->input->post('addresspostalcode', true)   ?? '',
			'addresscountry'       => $this->input->post('addresscountry', true)      ?? '',
			'soapbox'              => $this->input->post('soapbox', true)             ?? '',
			'certificate'          => $this->input->post('certificate', true)         ?? '',
			'grid_export'          => $this->input->post('grid_export', true)         ?? '0',
			'grid_precision'       => $this->input->post('grid_precision', true) === '6' ? '6' : '4',
		];

		$this->contesting_model->save_exportformat_settings($contest_session_id, "cabrillo", $cabrillo);

		$session_info = $this->contesting_model->get_session_info($contest_session_id);
		$qsos         = $this->contesting_model->get_session_qsos_for_exportformat($contest_session_id);

		$userinfo = $this->user_model->get_by_id($this->session->userdata('user_id'))->row();

		$contest_id = $session_info['contest_adifname'];
		$callsign   = strtoupper(str_replace('/', '-', $session_info['station_callsign'] ?? 'STATION'));
		$filename   = $callsign . '-' . $contest_id . '-' . date('Ymd-Hi') . '.cbr';

		$this->load->library('Cabrilloformat');

		header('Content-Type: text/plain; charset=utf-8');
		header('Content-Disposition: attachment; filename="' . $filename . '"');

		$grid_export = ($cabrillo['grid_export'] === '1');
		$grid_chars  = (int)($cabrillo['grid_precision'] ?? 4);

		$cbr_name  = $cabrillo['name']  ?: trim($userinfo->user_firstname . ' ' . $userinfo->user_lastname);
		$cbr_email = $cabrillo['email'] ?: $userinfo->user_email;

		echo $this->cabrilloformat->header(
			$contest_id,
			$callsign,
			null,
			$cabrillo['operators'] ?: $callsign,
			$cabrillo['club']      ?: null,
			$cabrillo['location']  ?: null,
			$cbr_name,
			$cabrillo['address']           ?? '',
			$cabrillo['addresscity']       ?? '',
			$cabrillo['addressprovince']   ?? '',
			$cabrillo['addresspostalcode'] ?? '',
			$cabrillo['addresscountry']    ?? '',
			$cabrillo['soapbox']  ?: '',
			$session_info['station_gridsquare'] ?: null,
			$cabrillo['category_overlay']     ?: '',
			$cabrillo['category_transmitter'],
			$cabrillo['category_time']        ?: '',
			$cabrillo['category_station'],
			$cabrillo['category_power'],
			$cabrillo['category_mode'],
			$cabrillo['category_band'],
			$cabrillo['category_assisted'],
			$cabrillo['category_operator'],
			$cbr_email,
			$cabrillo['certificate'] ?: null
		);

		foreach ($qsos->result() as $qso) {
			echo $this->cabrilloformat->qso($qso, $grid_export, $grid_chars);
		}

		echo $this->cabrilloformat->footer();
	}

	/**
	 * EDI (Reg1Test) export for a specific contest session.
	 * Saves Reg1Test settings back to the session before streaming the file.
	 * POST /contesting/export_reg1test/<id>
	 */
	public function export_reg1test($contest_session_id) {

		//load contesting model
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');

		//security checks
		if (!$this->contesting_model->check_user_contest($contest_session_id) || !clubaccess_check(6)) {
			show_404();
		}

		//load distance calculator
		$this->load->library('Qra');

		//load userinfo
		$userinfo = $this->user_model->get_by_id($this->session->userdata('user_id'))->row();

		//get session information
		$session_info = $this->contesting_model->get_session_info($contest_session_id);

		//get reg1test settings from user input
		$reg1test_settings = [
			'contestband'    		=> $this->input->post('contestband', true),
			'sentexchange'    		=> $this->input->post('sentexchange', true)    		?? '',
			'contestaddress1'       => $this->input->post('contestaddress1', true)      ?? '',
			'contestaddress2'       => $this->input->post('contestaddress2', true)      ?? '',
			'categoryoperator'      => $this->input->post('categoryoperator', true)     ?? 'SINGLE-OP',
			'club'     				=> $this->input->post('club', true)     			?? '',
			'responsible_operator' 	=> $this->input->post('responsible_operator', true) ?? '',
			'address1'        		=> $this->input->post('address1', true)        		?? '',
			'address2'     			=> $this->input->post('address2', true)     		?? '',
			'addresspostalcode'     => $this->input->post('addresspostalcode', true)    ?? '',
			'addresscity'           => $this->input->post('addresscity', true)          ?? '',
			'addresscountry'        => $this->input->post('addresscountry', true)       ?? '',
			'operatorphone'         => $this->input->post('operatorphone', true)        ?? '',
			'operators'             => $this->input->post('operators', true)            ?? '',
			'txequipment'           => $this->input->post('txequipment', true)          ?? '',
			'power'         		=> $this->input->post('power', true)         		?? '',
			'rxequipment'      		=> $this->input->post('rxequipment', true)     		?? '',
			'antenna'    			=> $this->input->post('antenna', true)   			?? '',
			'antennaheight'       	=> $this->input->post('antennaheight', true)        ?? '',
			'soapbox'              	=> $this->input->post('soapbox', true)              ?? '',
			'bandmultiplicator'     => $this->input->post('bandmultiplicator', true)    ?? '',
		];

		//save reg1test settings
		$this->contesting_model->save_exportformat_settings($contest_session_id, "reg1test", $reg1test_settings);

		//take reg1testsettings and use that as base for export data structure
		$data = $reg1test_settings;

		//get qsos and set qso data for export
		$data['qsos'] = $this->contesting_model->get_session_qsos($contest_session_id, $data['contestband']);

		//set contest header data for export
		$data['band'] = $this->input->post('contestband', true);
		$data['qso_count'] = count($data['qsos']);
		$data['contest_id'] = $session_info['contest_adifname'];
		$data['callsign'] = strtoupper(str_replace('/', '-', $session_info['station_callsign'] ?? 'STATION'));
		$data['gridlocator'] = $session_info['station_gridsquare'];
		$data['name'] = trim($userinfo->user_firstname . ' ' . $userinfo->user_lastname);
		$data['maxdistanceqso'] = $this->qra->getMaxDistanceQSO($session_info['station_gridsquare'], $data['qsos'], "K");
		$data['from'] = date('Ymd', strtotime($session_info['time_start']));
		$data['to'] = date('Ymd', strtotime($session_info['time_end']));

		//Load distance calculator
		$this->load->library('Reg1testformat');

		//Set headers
		header('Content-Type: text/plain; charset=utf-8');
		header('Content-Disposition: attachment; filename="' .  $data['callsign'] . '-' . $data['contest_id'] . '-' . date('Ymd-Hi') . '-' . $this->reg1testformat->reg1testbandstring($data['band']) . '.edi"');

		//calculate qso details
		$qsodetails = $this->reg1testformat->qsos($data['qsos'], $data['gridlocator'], $data['bandmultiplicator']);

		//get header
		echo $this->reg1testformat->header(
			$data['contest_id'],
			$data['from'],
			$data['to'],
			$data['callsign'],
			$data['gridlocator'],
			$data['contestaddress1'],
			$data['contestaddress2'],
			$data['categoryoperator'],
			$data['band'],
			$data['club'],
			$data['name'],
			$data['responsible_operator'],
			$data['address1'],
			$data['address2'],
			$data['addresspostalcode'],
			$data['addresscity'],
			$data['addresscountry'],
			$data['operatorphone'],
			$data['operators'],
			$data['soapbox'],
			$data['qso_count'],
			$data['sentexchange'],
			$data['txequipment'],
			$data['power'],
			$data['rxequipment'],
			$data['antenna'],
			$data['antennaheight'],
			$data['maxdistanceqso'],
			$data['bandmultiplicator'],
			$qsodetails['claimedpoints']
		);

		//write QSO details
		echo $qsodetails['formatted_qso'];

		//get seperate footer if QSO details won't provide one
		echo $data['qso_count'] < 1 ? $this->reg1testformat->footer() : '';

	}

	/**
	 * This starts the main logging engine for contesting
	 */
	public function logging_engine($contest_session_id = 0) {
		$this->load->model('contesting_model');

		$contest_session_id = intval($contest_session_id);

		// Security Check: Ensure the user is authorized for this contest session
		if (!$contest_session_id || !$this->contesting_model->check_user_contest($contest_session_id)) {
			$this->session->set_flashdata('error', __("You are not authorized to access this contest session."));
			redirect('contesting');
		}

		// setting up worker if available
		$worker_topic = 'contest_session.' . $contest_session_id; // shared topic for all operators in this contest session
		if ($this->worker_available) {
			$this->worker->register_topic($worker_topic);

			$data['worker_client_url'] = $this->worker->client_url();
			$data['worker_topic']      = $worker_topic;
			$data['worker_token']      = $this->worker->create_token($worker_topic);
		}

		// Load session data
		$data['session_info'] = $this->contesting_model->get_session_info($contest_session_id);
		if (!$data['session_info']) {
			$this->session->set_flashdata('error', __("Contest session not found."));
			redirect('contesting');
		}

		// if cache driver is dummy and serial numbers are required, log an error message
		if (in_array('serial', $data['session_info']['exchangefields'] ?? [], true)
			&& $this->cache->get_loaded_driver() === 'dummy') {
			log_message('error', 'Contest logger: no cache is available (dummy adapter), so serial numbers cannot be handed out per operator. Configure Redis/Valkey, Memcached, APCu or the file cache.');
		}

		// Per-user map preferences (night shadow / path line / station marker).
		// Stored in user_options (bound to user_id), default all on except grid (needs more ressources)
		$this->load->model('user_options_model');
		$map_prefs = ['nightshadow' => true, 'pathline' => true, 'station' => true, 'autofit' => true, 'grid' => false];
		$pref_row = $this->user_options_model->get_options('contest_logger_settings', ['option_name' => 'map_prefs']);
		if ($pref_row && $pref_row->num_rows() > 0) {
			$stored = json_decode($pref_row->row()->option_value, true);
			if (is_array($stored)) {
				$map_prefs = array_merge($map_prefs, $stored);
			}
		}
		$data['map_prefs'] = $map_prefs;

		// Generate storage key for localStorage. This needs to be collision-free between different Wavelog Instances and different users
		$data['storage_key'] = md5($this->config->item('base_url') . $contest_session_id . $this->session->userdata('user_id'));

		$data['operator'] = strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign')));
		$data['page_title'] = !empty($data['session_info']['custom_name'])
			? $data['session_info']['custom_name']
			: $data['session_info']['contest_name'];
		$data['is_club_station'] = (bool) ($this->session->userdata('clubstation') ?? false);
		$data['switch_operator_mode'] = null;
		if ($data['is_club_station'] && !($this->config->item('disable_switch_operator') ?? false)) {
			if (empty($this->session->userdata('source_uid'))) {
				$data['switch_operator_mode'] = 'callsign';
			} elseif (!$this->config->item('disable_impersonate')) {
				$data['switch_operator_mode'] = 'login';
			}
		}
		$data['club_callsign'] = $this->session->userdata('user_callsign');

		// Load available radios for CAT control
		$this->load->model('cat');
		$data['radios'] = $this->cat->radios();

		// Register a worker topic per radio so the browser can subscribe for real-time CAT updates
		$radio_worker_topics = [];
		if ($this->worker_available) {
			foreach ($data['radios']->result() as $radio) {
				$radio_topic = 'radio.' . $radio->id;
				$this->worker->register_topic($radio_topic);
				$radio_worker_topics[$radio->id] = [
					'topic' => $radio_topic,
					'token' => $this->worker->create_token($radio_topic),
				];
			}
		}
		$data['radio_worker_topics'] = $radio_worker_topics;

		// Load available modes for manual frequency/mode entry
		$this->load->model('usermodes');
		$data['modes'] = $this->usermodes->active();

		// Load Bands
		$this->load->model('bands');
		$data['bands'] = $this->bands->get_user_bands_for_qso_entry();
		log_message('debug', 'Loaded bands for contest logger: ' . json_encode($data['bands']));

		// Set custom date format
		if ($this->session->userdata('user_date_format')) {
			$data['custom_date_format'] = $this->session->userdata('user_date_format');
		} else {
			$data['custom_date_format'] = $this->config->item('qso_date_format');
		}

		// Component and layout configuration (percentage-based: 0-100)
		// Component names as keys, layout configuration as values
		// This can later be overridden by user-defined layouts
		$data['components'] = [
			'qso-form' => [
				'x' => 1,      // 1% from left
				'y' => 1,      // 1% from top
				'width' => 70, // 70% of workspace width
				'height' => 60, // 60% of workspace height
			],
			'radio' => [
				'x' => 73,     // 73% from left (right side)
				'y' => 1,      // 1% from top
				'width' => 26, // 26% of workspace width
				'height' => 30, // 30% of workspace height
			],
			'clock' => [
				'x' => 1,      // 1% from left
				'y' => 63,     // 63% from top (below qso-form)
				'width' => 20, // 20% of workspace width
				'height' => 12, // 12% of workspace height
			],
			'map' => [
				'x' => 22,     // 22% from left (next to clock)
				'y' => 63,     // 63% from top (below qso-form)
				'width' => 51, // 51% of workspace width
				'height' => 36, // 36% of workspace height
			],
			'scp' => [
				'x' => 73,     // 73% from left (right side)
				'y' => 32,     // 32% from top (below radio)
				'width' => 26, // 26% of workspace width
				'height' => 67, // 67% of workspace height
			],
			'statistics' => [
				'x'      => 1,      // 1% from left, below clock
				'y'      => 76,     // clock ends at y=75 (63+12), so 1% gap
				'width'  => 20,     // same width as clock for visual alignment
				'height' => 22,     // fills down to ~98%
			]
		];

		if ($this->session->userdata('isWinkeyEnabled')) {
			// Winkeyer sits at the bottom right (y=63). Shrink the SCP component
			// above it so both fit stacked without the winkeyer overlapping the SCP.
			$data['components']['scp']['height'] = 30; // 32% + 30% = 62%, leaving a 1% gap before the winkeyer at y=63
			$data['components']['winkeyer'] = [
				'x'      => 73,
				'y'      => 63,
				'width'  => 26,
				'height' => 35,
			];
		}

		$this->load->view('contesting/logger/header', $data);
		$this->load->view('contesting/logger/index');
		$this->load->view('contesting/logger/footer');
	}

	public function create_session() {
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
		switch ($this->input->method()) {
			case 'get':
				$this->load->is_loaded('contest_admin_model') ?: $this->load->model('contest_admin_model');
				$this->load->is_loaded('stations') ?: $this->load->model('stations');

				$data['available_contests'] = $this->contest_admin_model->getActiveContests();
				if (!empty($this->session->userdata('user_stations_active_log_only'))) {
					$data['stations'] = $this->logbooks_model->list_logbooks_linked($this->session->userdata('active_station_logbook'));
				} else {
					$data['stations'] = $this->stations->all_of_user();
				}
				$data['active_station_location'] = $this->active_station_location;

				$this->load->view('contesting/manager/components/session_modal', $data);
				break;

			case 'post':
				if (!clubaccess_check(9)) {
					$this->session->set_flashdata('error', __("Officers must set up contests."));
					redirect('contesting');
				}
				$contest_adif_id = $this->input->post('contest_adif_id', true);
				$session_start = $this->input->post('session_start', true);
				$session_end = $this->input->post('session_end', true);
				$station_location = $this->input->post('station_location', true);
				$session_notes = $this->input->post('session_notes', true);
				$exchangefields = $this->_parseExchangeFields($this->input->post('exchangefields', true));
				$exchangetype   = $this->_fieldsToLegacyType($exchangefields);
				$copyexchangeto = $this->input->post('copyexchangeto', true) ?? '';
				$callbook_lookup  = (bool) $this->input->post('callbook_lookup', true);
				$custom_name    = trim($this->input->post('custom_name', true) ?? '');
				$serial_per_band = (bool) $this->input->post('serial_per_band', true);
				$serial_scope    = $this->_parseSerialScope($this->input->post('serial_scope', true));

				$parameter_array = [
					'exchangetype'    => $exchangetype,
					'copyexchangeto'  => $copyexchangeto,
					'exchangefields'  => $exchangefields,
					'callbook_lookup' => $callbook_lookup,
					'custom_name'     => $custom_name,
					'serial_per_band' => $serial_per_band,
					'serial_scope'    => $serial_scope,
				];

				$result = $this->contesting_model->create_contest_session($contest_adif_id, $session_start, $session_end, $station_location, $session_notes, false, $parameter_array);

				if ($result) {
					$this->session->set_flashdata('success', __("Contest session created successfully."));
				} else {
					$this->session->set_flashdata('error', __("There was an error creating the contest session. Please try again."));
				}

				redirect('contesting');

			default:
				$this->session->set_flashdata('error', __("Invalid request method."));
				redirect('contesting');
		}
	}

	public function edit_session(){
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');

		switch ($this->input->method()) {
			case 'get':
				$contest_session_id = $this->input->get('contest_session_id');

				$this->load->is_loaded('contest_admin_model') ?: $this->load->model('contest_admin_model');
				$this->load->is_loaded('stations') ?: $this->load->model('stations');

				$data['session_info'] = $this->contesting_model->get_session_info($contest_session_id);
				if (!$data['session_info']) {
					$this->session->set_flashdata('error', __("Contest session not found."));
					redirect('contesting');
				}

				$data['available_contests'] = $this->contest_admin_model->getActiveContests();
				$data['stations'] = $this->stations->all_of_user();
				$data['active_station_location'] = $this->active_station_location;

				$this->load->view('contesting/manager/components/session_modal', $data);
				break;

			case 'post':
				$contest_session_id = $this->input->post('contest_session_id', true);
				$time_start = $this->input->post('session_start', true);
				$time_end = $this->input->post('session_end', true);
				$station_id = $this->input->post('station_location', true);
				$notes = $this->input->post('session_notes', true);
				$contest_id = $this->input->post('contest_adif_id', true);
				$exchangefields = $this->_parseExchangeFields($this->input->post('exchangefields', true));
				$exchangetype   = $this->_fieldsToLegacyType($exchangefields);
				$copyexchangeto = $this->input->post('copyexchangeto', true) ?? '';
				$callbook_lookup  = (bool) $this->input->post('callbook_lookup', true);
				$custom_name    = trim($this->input->post('custom_name', true) ?? '');
				$serial_per_band = (bool) $this->input->post('serial_per_band', true);
				$serial_scope    = $this->_parseSerialScope($this->input->post('serial_scope', true));

				$parameter_array = [
					'exchangetype'    => $exchangetype,
					'copyexchangeto'  => $copyexchangeto,
					'exchangefields'  => $exchangefields,
					'callbook_lookup' => $callbook_lookup,
					'custom_name'     => $custom_name,
					'serial_per_band' => $serial_per_band,
					'serial_scope'    => $serial_scope,
				];

				$result = $this->contesting_model->update_contest_session($contest_session_id, $contest_id, $time_start, $time_end, $station_id, $notes, $parameter_array);

				if ($result) {
					$this->cache->delete(self::CACHE_KEY_SESSION_SETTINGS . $contest_session_id); // Clear session cache to reflect changes immediately
					if ($this->worker_available) {
						$this->worker->publish('contest_session.' . $contest_session_id, ['type' => 'settings_changed']);
					}
					$this->session->set_flashdata('success', __("Contest session updated successfully."));
				} else {
					$this->session->set_flashdata('error', __("There was an error updating the contest session. Please try again."));
				}

				redirect('contesting');

			default:
				$this->session->set_flashdata('error', __("Invalid request method."));
				redirect('contesting');
		}
	}

	public function delete_session(){
		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');

		switch ($this->input->method()) {
			case 'get':
				$contest_session_id = $this->input->get('contest_session_id');

				$this->load->is_loaded('contest_admin_model') ?: $this->load->model('contest_admin_model');
				$this->load->is_loaded('stations') ?: $this->load->model('stations');

				$data['session_info'] = $this->contesting_model->get_session_info($contest_session_id);
				if (!$data['session_info']) {
					$this->session->set_flashdata('error', __("Contest session not found."));
					redirect('contesting');
				}

				$data['available_contests'] = $this->contest_admin_model->getActiveContests();
				if (!empty($this->session->userdata('user_stations_active_log_only'))) {
					$data['stations'] = $this->logbooks_model->list_logbooks_linked($this->session->userdata('active_station_logbook'));
				} else {
					$data['stations'] = $this->stations->all_of_user();
				}
				$data['active_station_location'] = $this->active_station_location;

				$this->load->view('contesting/manager/components/confirm_delete', $data);
				break;

			case 'post':
				$contest_session_id = $this->input->post('contest_session_id', true);
				$delete_qsos = (bool) $this->input->post('delete_qsos', true);

				$result = $this->contesting_model->delete_contest_session($contest_session_id, $delete_qsos);

				if ($result) {
					$this->session->set_flashdata('success', __("Contest session deleted successfully."));
				} else {
					$this->session->set_flashdata('error', __("There was an error deleting the contest session. Please try again."));
				}

				redirect('contesting');

			default:
				$this->session->set_flashdata('error', __("Invalid request method."));
				redirect('contesting');
		}
	}

	/**
	 * Deletes multiple contest sessions at once.
	 * Endpoint: POST /contesting/batch_delete_sessions
	 * POST: ids = comma-separated contest_session ids, delete_qsos = optional flag
	 */
	public function batch_delete_sessions() {
		if ($this->input->method() !== 'post') {
			$this->session->set_flashdata('error', __("Invalid request method."));
			redirect('contesting');
		}

		if (!clubaccess_check(9)) {
			$this->session->set_flashdata('error', __("Only clubstation officers can delete."));
			redirect('contesting');
		}

		$ids = array_filter(array_map('intval', explode(',', (string)$this->input->post('ids', true))));
		$delete_qsos = (bool) $this->input->post('delete_qsos', true);

		if (empty($ids)) {
			$this->session->set_flashdata('error', __("No contest sessions selected."));
			redirect('contesting');
		}

		$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
		$deleted = $this->contesting_model->batch_delete_sessions($ids, $delete_qsos);

		if ($deleted > 0) {
			$this->session->set_flashdata('success', sprintf(_ngettext("%d contest session deleted.", "%d contest sessions deleted.", $deleted), $deleted));
		} else {
			$this->session->set_flashdata('error', __("There was an error deleting the contest sessions. Please try again."));
		}

		redirect('contesting');
	}

	/**
	 * Inline QSO edit endpoint.
	 * Endpoint: POST /contesting/update_qso
	 *
	 * Accepts JSON: { contest_session_id, qso_id, callsign?, mode?, frequency?,
	 *                 rst_sent?, rst_rcvd?, serial_sent?, serial_rcvd?,
	 *                 exchange_sent?, exchange_rcvd?, gridsquare_rcvd? }
	 *
	 * Authorization: PHP session user must own the contest session AND be the
	 * operator recorded on the QSO (operator check).
	 */
	public function update_qso() {
		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		header('Content-Type: application/json');
		session_write_close();

		try {
			$payload = json_decode($this->input->raw_input_stream, true);
			if (!$payload) {
				throw new Exception('Invalid JSON payload');
			}

			$contest_session_id = (int)($payload['contest_session_id'] ?? 0);
			$qso_id = (int)($payload['qso_id'] ?? 0);

			if (!$contest_session_id || !$qso_id) {
				throw new Exception('Missing contest_session_id or qso_id');
			}

			$this->load->model('contesting_model');
			$this->load->is_loaded('logbook_model') ?: $this->load->model('logbook_model');

			// Session ownership check
			if (!$this->contesting_model->check_user_contest($contest_session_id)) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => 'Access denied']);
				return;
			}

			// Verify QSO belongs to this session and get its operator
			$qso = $this->contesting_model->get_contest_qso($qso_id, $contest_session_id);
			if (!$qso) {
				http_response_code(404);
				echo json_encode(['success' => false, 'error' => 'QSO not found in this session']);
				return;
			}

			// Operator check: only the user who logged the QSO may edit it.
			// In club-station mode user_callsign is the club callsign; operator_callsign holds the personal callsign that was written to COL_OPERATOR.
			$current_callsign = strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign')));
			$qso_operator = strtoupper(trim($qso['operator'] ?? ''));
			if ($qso_operator !== $current_callsign) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => 'You can only edit QSOs you logged']);
				return;
			}

			// Whitelist of editable columns
			$allowed = [
				'callsign'      => 'COL_CALL',
				'mode'          => 'COL_MODE',
				'frequency'     => 'COL_FREQ',
				'band'          => 'COL_BAND',
				'rst_sent'      => 'COL_RST_SENT',
				'rst_rcvd'      => 'COL_RST_RCVD',
				'serial_sent'   => 'COL_STX',
				'serial_rcvd'   => 'COL_SRX',
				'exchange_sent' => 'COL_STX_STRING',
				'exchange_rcvd' => 'COL_SRX_STRING',
				'gridsquare_rcvd' => 'COL_GRIDSQUARE',
				'time_on'         => 'COL_TIME_ON',
			];

			$fields = [];
			foreach ($allowed as $key => $col) {
				if (array_key_exists($key, $payload)) {
					$val = $payload[$key];
					if (in_array($key, ['callsign', 'mode', 'rst_sent', 'rst_rcvd',
					                    'serial_sent', 'serial_rcvd', 'exchange_sent',
					                    'exchange_rcvd', 'gridsquare_rcvd'])) {
						$val = $val !== null ? strtoupper(trim((string)$val)) : null;
						if ($key === 'callsign' && $val !== null) {
							$val = $this->_validateCallsign($val);
						}
					}
					// Bands are stored lowercase (e.g. 20m, 70cm)
					if ($key === 'band') {
						$val = $val !== null ? strtolower(trim((string)$val)) : null;
					}
					// Frequency is user-editable now; normalize to an Hz integer (also accepts unit suffixes).
					if ($key === 'frequency') {
						$val = ($val !== null && trim((string)$val) !== '') ? $this->logbook_model->parse_frequency((string)$val) : null;
					}
					if (in_array($key, ['serial_sent', 'serial_rcvd']) && $val === '') {
						$val = null;
					}
					if ($key === 'time_on' && $val !== null) {
						$dt = DateTime::createFromFormat('Y-m-d H:i:s', trim((string)$val));
						// getLastErrors() catches overflow (e.g. 25:00:00 silently wrapping to next day)
						$dt_errors = DateTime::getLastErrors();
						if (!$dt || ($dt_errors && array_sum($dt_errors))) throw new Exception('Invalid time_on value');
						$val = $dt->format('Y-m-d H:i:s');
					}
					$fields[$col] = $val;
					if ($key === 'time_on') $fields['COL_TIME_OFF'] = $val;
				}
			}

			if (empty($fields)) {
				throw new Exception('No editable fields provided');
			}

			$this->contesting_model->update_contest_qso($qso_id, $fields);

			if ($this->worker_available) {
				$this->worker->publish('contest_session.' . $contest_session_id, ['type' => 'sync_required']);
			}

			echo json_encode(['success' => true, 'qso_id' => $qso_id]);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode(['success' => false, 'error' => $e->getMessage()]);
		}
	}

	/**
	 * Delete a single QSO from a contest session and from the main logbook.
	 * Endpoint: POST /contesting/delete_qso
	 *
	 * Accepts JSON: { contest_session_id, qso_id }
	 */
	public function delete_qso() {
		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		header('Content-Type: application/json');
		session_write_close();

		try {
			$payload = json_decode($this->input->raw_input_stream, true);
			if (!$payload) {
				throw new Exception('Invalid JSON payload');
			}

			$contest_session_id = (int)($payload['contest_session_id'] ?? 0);
			$qso_id             = (int)($payload['qso_id'] ?? 0);

			if (!$contest_session_id || !$qso_id) {
				throw new Exception('Missing contest_session_id or qso_id');
			}

			$this->load->model('contesting_model');

			if (!$this->contesting_model->check_user_contest($contest_session_id)) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => 'Access denied']);
				return;
			}

			$qso = $this->contesting_model->get_contest_qso($qso_id, $contest_session_id);
			if (!$qso) {
				http_response_code(404);
				echo json_encode(['success' => false, 'error' => 'QSO not found in this session']);
				return;
			}

			$current_callsign = strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign')));
			$qso_operator     = strtoupper(trim($qso['operator'] ?? ''));
			if ($qso_operator !== $current_callsign) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => 'You can only delete QSOs you logged']);
				return;
			}

			$this->contesting_model->unlink_qso($qso_id, $contest_session_id);

			$this->load->model('logbook_model');
			$this->logbook_model->delete($qso_id);

			if ($this->worker_available) {
				$this->worker->publish('contest_session.' . $contest_session_id, ['type' => 'sync_required']);
			}

			echo json_encode(['success' => true, 'qso_id' => $qso_id]);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode(['success' => false, 'error' => $e->getMessage()]);
		}
	}

	/**
	 * Switch the operating user mid-session in a club station.
	 */
	public function switch_operator() {
		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		header('Content-Type: application/json');

		try {
			if (!$this->config->item('special_callsign') || $this->config->item('disable_impersonate')) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => __("Operator switching is disabled.")]);
				return;
			}

			$payload = json_decode($this->input->raw_input_stream, true);
			if (!$payload) {
				throw new Exception('Invalid JSON payload');
			}

			$contest_session_id = (int)($payload['contest_session_id'] ?? 0);
			$user_name          = trim((string)($payload['user_name'] ?? ''));
			$user_password      = (string)($payload['user_password'] ?? '');

			if (!$contest_session_id || $user_name === '' || $user_password === '') {
				throw new Exception('Missing required fields');
			}

			// Only valid inside a club station.
			if ((int)$this->session->userdata('clubstation') !== 1) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => __("Operator switching is only available in club stations.")]);
				return;
			}

			$club_id = (int)$this->session->userdata('user_id');

			// The club must own this contest session.
			$this->load->model('contesting_model');
			if (!$this->contesting_model->check_user_contest($contest_session_id)) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => __("Access denied")]);
				return;
			}

			// Re-authenticate the new operator. Reuses bcrypt verification and the
			// login_attempts lockout. Returns 1 on success; 0/2/3 are all failures here.
			if ($this->user_model->authenticate($user_name, $user_password) !== 1) {
				http_response_code(401);
				echo json_encode(['success' => false, 'error' => __("Incorrect username or password!")]);
				return;
			}
			$op = $this->user_model->get($user_name)->row();

			// A club account must never act as an operator. authenticate() lets club
			// accounts through when club_direct is enabled, so reject them explicitly.
			if ((int)$op->clubstation !== 0) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => __("A club account cannot be used as an operator.")]);
				return;
			}

			// The operator must be an authorised member (p_level >= 3) of THIS club.
			// get_permission_noui() is the redirect-free getter (club_authorize() would
			// redirect on failure, which breaks a JSON endpoint).
			$this->load->model('club_model');
			$p_level = (int)$this->club_model->get_permission_noui($club_id, $op->user_id);
			if ($p_level < 3) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => __("You're not allowed to do that!")]);
				return;
			}

			// Establish the impersonation session for the new operator (same primitives
			// as User::impersonate()). user_id stays the club.
			// Create a fresh impersonation hash so the operator can still use "Switch back"
			// to return to their own account. Format must match stop_impersonate()'s
			// validation (User.php): source_uid/target_uid/timestamp — source is this
			// operator, target is the club (which stays as user_id).
			if (!$this->load->is_loaded('encryption')) {
				$this->load->library('encryption');
			}
			$custom = [
				'p_level'       => $p_level,
				'src_call'      => $op->user_callsign,
				'src_user_type' => $op->user_type,
				'src_hash'      => $this->encryption->encrypt($op->user_id . '/' . $club_id . '/' . time()),
			];
			$this->session->set_userdata('source_uid', $op->user_id);
			$this->user_model->update_session($club_id, null, true, $custom);
			// update_session keeps an existing operator_callsign, so override it explicitly.
			$this->session->set_userdata('operator_callsign', strtoupper(trim($op->user_callsign)));

			// Reload the engine; it picks up the new operator from the session.
			echo json_encode([
				'success'  => true,
				'redirect' => site_url('contesting/logging_engine') . '/' . intval($contest_session_id),
			]);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode(['success' => false, 'error' => $e->getMessage()]);
		}
	}

	/**
	 * Resolves which serial pool a request belongs to.
	 *
	 * @param array $session_info Result of Contesting_model::get_session_info()
	 * @param string|null $band Band reported by the client.
	 * @return array [band, operator], each null when the pool is not scoped by it.
	 */
	private function _serialPool($session_info, $band = null) {
		$pool_band = null;
		$pool_operator = null;

		if (!empty($session_info['serial_per_band'])) {
			// Format check only, no db required here
			$band = strtolower(trim($band ?? ''));
			$pool_band = preg_match('/^\d{1,4}(\.\d{1,2})?(mm|cm|m)$/', $band) ? $band : '';
		}

		if (($session_info['serial_scope'] ?? 'station') === 'operator') {
			$pool_operator = strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign')));
		}

		return [$pool_band, $pool_operator];
	}

	/**
	 * Sync Endpoint for Contest Engine
	 * Handles bidirectional communication (Commands + Requests)
	 * Endpoint: POST /contesting/heartbeat
	 */
	public function heartbeat() {
		// Only accept POST requests
		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		header('Content-Type: application/json');
		session_write_close();

		try {
			// Get JSON payload
			$json_input = $this->input->raw_input_stream;
			$payload = json_decode($json_input, true);

			if (!$payload) {
				throw new Exception('Invalid JSON payload');
			}

			$session_id = (int)($payload['session_info']['contest_session_id'] ?? 0);
			if (!$session_id) {
				throw new Exception('Missing contest_session_id');
			}

			$this->load->model('contesting_model');

			if (!$this->contesting_model->check_user_contest($session_id)) {
				http_response_code(403);
				echo json_encode(['success' => false, 'error' => 'Access denied']);
				return;
			}

			$session_info = $this->contesting_model->get_session_info($session_id);
			if (!$session_info) {
				throw new Exception('Contest session not found');
			}

			$response = [
				'success' => true,
				'commands_processed' => 0,
				'data' => [
					'saved_qsos' => [],
					'needs_resync' => false,
					'all_qsos' => null,
					'changed_qsos' => []
				],
				'errors' => []
			];

			// Process Commands (Push Operations from client to server)
			if (isset($payload['commands']) && is_array($payload['commands'])) {
				foreach ($payload['commands'] as $command) {
					try {
						$response['commands_processed'] += $this->_processCommand($command, $session_info);
					} catch (Exception $e) {
						$response['errors'][] = $e->getMessage();
					}
				}
			}

			// Transfer saved QSOs from commands to response
			$response['data']['saved_qsos'] = $this->new_qsos;

			// Process Requests (Pull Operations from server to client)
			if (isset($payload['requests']) && is_array($payload['requests'])) {
				foreach ($payload['requests'] as $request) {
					try {
						$this->_processRequest($request, $session_info, $response);
					} catch (Exception $e) {
						$response['errors'][] = $e->getMessage();
					}
				}
			}

			echo json_encode($response);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode([
				'success' => false,
				'error' => $e->getMessage(),
				'data' => []
			]);
		}
	}

	/**
	 * Process a single command (server write operation)
	 * @private
	 */
	private function _processCommand($command, $session_info) {
		if (!isset($command['type'])) {
			throw new Exception('Command missing type');
		}

		switch ($command['type']) {
			case 'save_qso':
				if (!isset($command['data'])) {
					throw new Exception('save_qso command missing data');
				}

				$command['data']['callsign'] = $this->_validateCallsign($command['data']['callsign'] ?? '');

				// Only load models if needed
				$this->load->is_loaded('logbook_model') ?: $this->load->model('logbook_model');
				$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');

				// Prepare QSO data for saving
				$qso_data = [
					'manual' => 0, // work always as non-manual entry; TODO: Implement something like POST CONTEST LOGGING
					'start_date' => $command['data']['date'],
					'start_time' => $command['data']['time'],
					'end_time' => $command['data']['time'],
					'callsign' => $command['data']['callsign'],
					'freq_display' => $command['data']['frequency'],
					'mode' => $command['data']['mode'],
					'rst_sent' => $command['data']['rst_sent'],
					'rst_rcvd' => $command['data']['rst_rcvd'],
					'exch_serial_s' => $command['data']['serial_sent'] ?? NULL,
					'exch_serial_r' => $command['data']['serial_rcvd'] ?? NULL,
					'exch_sent' => $command['data']['exchange_sent'] ?? NULL,
					'exch_rcvd' => $command['data']['exchange_rcvd'] ?? NULL,
					'locator' => $command['data']['gridsquare_rcvd'] ?? NULL,
					'name'    => $command['data']['name']          ?? NULL,
					'qth'     => $command['data']['qth']           ?? NULL,
					'ituz'    => $command['data']['ituz']          ?? NULL,
					'country' => $command['data']['country'] ?? NULL,
					'continent' => $command['data']['continent'] ?? NULL,
					'dxcc_id' => $command['data']['dxcc_id'] ?? NULL,
					'cqz' => $command['data']['cqz'] ?? NULL,
					'operator_callsign' => strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign'))),
					'station_profile' => $session_info['station_id'],
					'contestname' => $session_info['contest_adifname'],
					'exchangetype' => $session_info['exchangetype'] ?? 'Exchange',
					'copyexchangeto' => $session_info['copyexchangeto'] ?? NULL
				];

				// Save QSO to database
				$save_result = $this->logbook_model->create_qso($qso_data, false);

				// Link QSO to contest session
				if (is_array($save_result) && !empty($save_result['qso_id'])) {
					$this->contesting_model->link_qso($save_result['qso_id'], $session_info['contest_session_id']);

					// Keep the counter ahead of the log: if the operator overrode the
					// serial in the form by hand, the counter would otherwise hand the
					// same number out again.
					if (!empty($command['data']['serial_sent'])) {
						list($pool_band, $pool_operator) = $this->_serialPool($session_info, $command['data']['band'] ?? null);
						$this->contesting_model->serial_bump(
							$session_info['contest_session_id'],
							$pool_band,
							$pool_operator,
							$command['data']['serial_sent']
						);
					}

					// Notify worker clients about new QSO if worker is available
					if ($this->worker_available) {
						$this->worker->publish('contest_session.' . $session_info['contest_session_id'], ['type' => 'sync_required']);
						log_message('debug', 'published sync_required for contest session ' . $session_info['contest_session_id']);
					}
				} else {
					throw new Exception('Failed to save QSO');;
				}

				// Store mapping of tmp_id to real ID for client response
				// This will be sent back to the client to update local state.
				// last_modified_ms lets the client advance its since_ts watermark so the
				// next check_sync does not pull this QSO back as a delta. The row was just
				// inserted with last_modified = NOW(), so the current server time in ms
				// matches it at the second precision the delta query compares on.
				$this->new_qsos[] = [
					'tmp_id' => $command['data']['tmp_id'],
					'server_id' => $save_result['qso_id'],
					'last_modified_ms' => time() * 1000
				];

				return 1; // 1 command processed

			default:
				throw new Exception("Unknown command type: {$command['type']}");
		}
	}

	/**
	 * Process a single request (server read operation)
	 * @private
	 */
	private function _processRequest($request, $session_info, &$response) {
		if (!isset($request['type'])) {
			throw new Exception('Request missing type');
		}

		switch ($request['type']) {
			case 'get_radio_status':
				// Radio CAT status request
				if (!isset($request['radio_id']) || $request['radio_id'] === '0') {
					break; // No radio selected
				}

				$this->load->model('cat');
				$radio_query = $this->cat->radio_status($request['radio_id']);

				if ($radio_query->num_rows() > 0) {
					$row = $radio_query->row();
					$response['data']['radio_status'] = [
						'frequency' => $row->frequency,
						'mode' => $row->mode,
						'timestamp' => strtotime($row->timestamp) * 1000, // Unix timestamp in ms
						'updated_minutes_ago' => floor((time() - strtotime($row->timestamp)) / 60)
					];
				} else {
					$response['data']['radio_status'] = null;
				}
				break;

			case 'check_sync':
				// Incremental sync: deliver only QSOs changed since the client's watermark.
				// Adds and edits (contest editor AND external logbook/ADIF/QSL edits) bump
				// last_modified, so the delta query catches them all. Deletes do not bump
				// anything (the row is gone), so they are caught by the QSO count check below,
				// which falls back to a one-off full resync — rare and acceptable.
				$client_qso_count = $request['client_qso_count'] ?? 0;
				$since_ts         = (int)($request['since_ts'] ?? 0);
				$since_id         = (int)($request['since_id'] ?? 0);

				$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
				$server_qso_count = $this->contesting_model->get_session_qso_count($session_info['contest_session_id']);

				// Make server count available to response without an extra query later
				$response['server_qso_count'] = $server_qso_count;

				// Expected client count after the client applies the QSOs saved this heartbeat
				$expected_client_count = $client_qso_count + count($response['data']['saved_qsos']);

				if ($expected_client_count !== $server_qso_count) {
					// Count mismatch → a delete (or a gap we cannot express as a delta) happened.
					// Send the full set so the client can replace its local state.
					$response['data']['needs_resync'] = true;
					$response['data']['all_qsos'] = $this->_map_qso_ids(
						$this->contesting_model->get_session_qsos_since($session_info['contest_session_id'], 0, 0)
					);

					log_message('info', "Resync triggered for session {$session_info['contest_session_id']}: Client={$client_qso_count} + Saved=" . count($response['data']['saved_qsos']) . " = {$expected_client_count}, Server={$server_qso_count}");
				} else {
					// Normal case → only the QSOs changed since the client's watermark.
					$response['data']['needs_resync'] = false;
					$response['data']['changed_qsos'] = $this->_map_qso_ids(
						$this->contesting_model->get_session_qsos_since($session_info['contest_session_id'], $since_ts, $since_id)
					);
				}
				break;

			case 'get_session_settings':
				$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
				$cache_key = self::CACHE_KEY_SESSION_SETTINGS . $session_info['contest_session_id'];
				$cached_settings = $this->cache->get($cache_key);
				log_message('info', "Cache lookup for session settings with key {$cache_key}: " . ($cached_settings ? 'HIT' : 'MISS'));
				if ($cached_settings) {
					$response['data']['session_settings'] = $cached_settings;
					break;
				}
				$fresh = $this->contesting_model->get_session_info($session_info['contest_session_id']);
				$response['data']['session_settings'] = $fresh;
				$this->cache->save($cache_key, $response['data']['session_settings'], 3600); // Cache for 1h
				break;

			case 'serial_state':
				$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
				list($pool_band, $pool_operator) = $this->_serialPool($session_info, $request['band'] ?? null);
				$response['data']['next_serial'] = $this->contesting_model->serial_peek(
					$session_info['contest_session_id'],
					$pool_band,
					$pool_operator
				);
				break;

			case 'claim_serial':
				$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
				list($pool_band, $pool_operator) = $this->_serialPool($session_info, $request['band'] ?? null);
				$claimed = $this->contesting_model->serial_claim(
					$session_info['contest_session_id'],
					$pool_band,
					$pool_operator
				);

				$response['data']['claimed_serial'] = ($claimed === false) ? null : $claimed;

				// Let the other operators refresh their preview right away.
				if ($claimed !== false && $this->worker_available) {
					$this->worker->publish('contest_session.' . $session_info['contest_session_id'], ['type' => 'serial_changed']);
				}
				break;

			case 'release_serial':
				$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
				list($pool_band, $pool_operator) = $this->_serialPool($session_info, $request['band'] ?? null);
				$released = $this->contesting_model->serial_release(
					$session_info['contest_session_id'],
					$pool_band,
					$pool_operator,
					$request['serial'] ?? 0
				);
				$response['data']['next_serial'] = $this->contesting_model->serial_peek(
					$session_info['contest_session_id'],
					$pool_band,
					$pool_operator
				);

				// Only a successful rollback changes anything for the others.
				if ($released && $this->worker_available) {
					$this->worker->publish('contest_session.' . $session_info['contest_session_id'], ['type' => 'serial_changed']);
				}
				break;

			default:
				throw new Exception("Unknown request type: {$request['type']}");
		}
	}

	/**
	 * Maps each QSO's qso_id to an id field for frontend compatibility.
	 * @param array|mixed $qsos Result of a get_session_qsos* query
	 * @return array
	 */
	private function _map_qso_ids($qsos) {
		if (!is_array($qsos)) {
			return [];
		}
		return array_map(function ($qso) {
			$qso['id'] = $qso['qso_id'];
			return $qso;
		}, $qsos);
	}

	/**
	 * Get all saved layouts for user
	 * POST: /contesting/get_layouts
	 */
	public function get_layouts() {
		header('Content-Type: application/json');

		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		session_write_close();

		try {
			$this->load->model('user_options_model');
			$result = $this->user_options_model->get_options('contest_logger_layout');

			// Get default layout name
			$default_result = $this->user_options_model->get_options('contest_logger_settings', ['option_name' => 'default_layout']);
			$default_layout = null;
			if ($default_result && $default_result->num_rows() > 0) {
				$default_layout = $default_result->row()->option_value;
			}

			$layouts = [];
			if ($result && $result->num_rows() > 0) {
				foreach ($result->result() as $row) {
					$layouts[] = [
						'name' => $row->option_name,
						'data' => json_decode($row->option_value, true),
						'is_default' => ($row->option_name === $default_layout)
					];
				}
			}

			echo json_encode([
				'success' => true,
				'layouts' => $layouts,
				'default_layout' => $default_layout
			]);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode([
				'success' => false,
				'error' => $e->getMessage()
			]);
		}
	}

	/**
	 * Set default layout for contest logger
	 * POST: /contesting/set_default_layout
	 */
	public function set_default_layout() {
		header('Content-Type: application/json');

		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		session_write_close();

		try {
			$json_input = $this->input->raw_input_stream;
			$payload = json_decode($json_input, true);

			if (!$payload || !isset($payload['name'])) {
				throw new Exception('Missing required field: name');
			}

			$name = $payload['name'];

			// Save default layout preference
			$this->load->model('user_options_model');
			$this->user_options_model->set_option(
				'contest_logger_settings',
				'default_layout',
				['value' => $name]
			);

			echo json_encode([
				'success' => true,
				'message' => 'Default layout set successfully'
			]);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode([
				'success' => false,
				'error' => $e->getMessage()
			]);
		}
	}

	/**
	 * Save per-user contest map preferences (night shadow / path line / station marker).
	 * POST: /contesting/save_map_prefs
	 * Stored per-user in user_options (bound to user_id), so other users on the same
	 * device do not inherit them.
	 */
	public function save_map_prefs() {
		header('Content-Type: application/json');

		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		session_write_close();

		try {
			$payload = json_decode($this->input->raw_input_stream, true);
			if (!is_array($payload)) {
				throw new Exception('Invalid payload');
			}

			// Whitelist + normalise to booleans
			$prefs = [
				'nightshadow' => !empty($payload['nightshadow']),
				'pathline'    => !empty($payload['pathline']),
				'station'     => !empty($payload['station']),
				'autofit'     => !empty($payload['autofit']),
				'grid'        => !empty($payload['grid']),
			];

			$this->load->model('user_options_model');
			$this->user_options_model->set_option(
				'contest_logger_settings',
				'map_prefs',
				['value' => json_encode($prefs)]
			);

			echo json_encode(['success' => true]);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode([
				'success' => false,
				'error' => $e->getMessage()
			]);
		}
	}

	/**
	 * Save user layout for contest logger
	 * POST: /contesting/save_layout
	 */
	public function save_layout() {
		header('Content-Type: application/json');

		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		session_write_close();

		try {
			$json_input = $this->input->raw_input_stream;
			$payload = json_decode($json_input, true);

			if (!$payload || !isset($payload['layout']) || !isset($payload['name'])) {
				throw new Exception('Missing required fields: layout or name');
			}

			$layout = $payload['layout'];
			$name = trim($payload['name']);

			if (empty($name)) {
				throw new Exception('Layout name cannot be empty');
			}

			// Save layout to user_options
			$this->load->model('user_options_model');
			$this->user_options_model->set_option(
				'contest_logger_layout',
				$name,
				['data' => json_encode($layout)]
			);

			echo json_encode([
				'success' => true,
				'message' => 'Layout saved successfully'
			]);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode([
				'success' => false,
				'error' => $e->getMessage()
			]);
		}
	}

	/**
	 * Load user layout for contest logger
	 * POST: /contesting/load_layout
	 */
	public function load_layout() {
		header('Content-Type: application/json');

		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		session_write_close();

		try {
			$json_input = $this->input->raw_input_stream;
			$payload = json_decode($json_input, true);

			if (!$payload || !isset($payload['name'])) {
				throw new Exception('Missing required field: name');
			}

			$name = $payload['name'];

			// Load layout from user_options
			$this->load->model('user_options_model');
			$result = $this->user_options_model->get_options(
				'contest_logger_layout',
				['option_name' => $name]
			);

			if ($result && $result->num_rows() > 0) {
				$row = $result->row();
				$layout = json_decode($row->option_value, true);

				echo json_encode([
					'success' => true,
					'layout' => $layout
				]);
			} else {
				echo json_encode([
					'success' => true,
					'layout' => null
				]);
			}
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode([
				'success' => false,
				'error' => $e->getMessage()
			]);
		}
	}

	/**
	 * Delete user layout for contest logger
	 * POST: /contesting/delete_layout
	 */
	public function delete_layout() {
		header('Content-Type: application/json');

		if ($this->input->method() !== 'post') {
			$this->_teapot();
			return;
		}

		session_write_close();

		try {
			$json_input = $this->input->raw_input_stream;
			$payload = json_decode($json_input, true);

			if (!$payload || !isset($payload['name'])) {
				throw new Exception('Missing required field: name');
			}

			$name = $payload['name'];

			// Delete layout from user_options
			$this->load->model('user_options_model');

			// Delete specific layout
			$this->user_options_model->del_option(
				'contest_logger_layout',
				$name
			);

			echo json_encode([
				'success' => true,
				'message' => 'Layout deleted successfully'
			]);
		} catch (Exception $e) {
			http_response_code(400);
			echo json_encode([
				'success' => false,
				'error' => $e->getMessage()
			]);
		}
	}

	/*
	 * Combined DXCC + callbook lookup for the contest engine.
	 * DXCC comes from the local library (fast, no network). Callbook data (name, QTH,
	 * grid, etc.) is fetched externally and merged on top, overriding DXCC fields where
	 * the callbook has more specific data. Result is cached server-side for 4 hours.
	 */
	public function callbook() {
		session_write_close();

		$this->load->library('callbook');
		$call = $this->input->get('call', TRUE);
		if (!$call || strlen($call) < 3) {
			http_response_code(400);
			echo json_encode(['error' => 'invalid call']);
			return;
		}
		$call = $this->callbook->get_plaincall($call);

		$offline_lookup = $this->input->get('offline', TRUE) === '1' ? true : false;

		// Local DXCC lookup — always available, no network needed
		$date    = date("Y-m-d");
		$dxccobj = new Dxcc();
		$dxcc    = $dxccobj->dxcc_lookup($call, $date);

		$payload = [
			'name'   => null,
			'qth'    => null,
			'grid'   => null,
			'ituz'   => null,
			'dxcc'   => $dxcc['adif']   ?? null,
			'cqz'    => $dxcc['cqz']    ?? null,
			'cont'   => $dxcc['cont']   ?? null,
			'entity' => $dxcc['entity'] ?? null,
			'lat'    => $dxcc['lat']    ?? null,
			'long'   => $dxcc['long']   ?? null,
			'source' => null,
		];

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

		// DB-first: get all available info from own log
		$db_info = $this->logbook_model->get_callsign_all_info($call);

		$payload['name'] = $db_info['name'] ?: null;
		$payload['qth']  = $db_info['qth']  ?: null;
		$payload['grid'] = $db_info['qra']  ?: null;
		$payload['ituz'] = $db_info['ituz'] ?: null;
		if (!empty($db_info['cqz'])) $payload['cqz'] = $db_info['cqz'];
		if ($payload['grid']) $payload['source'] = 'log';

		if (!$offline_lookup) {
			// we want to cache callbook lookups :)
			$cache_key = 'contesting_callbook_' . strtolower($call);
			$cbdata = $this->cache->get($cache_key);

			if ($cbdata === FALSE) {
				$cbdata = $this->logbook_model->loadCallBook($call);
				if (!empty($cbdata) && empty($cbdata['error'])) {
					$this->cache->save($cache_key, $cbdata, 14400); // 4h cache time for online lookups
				}
			}

			if (!empty($cbdata) && empty($cbdata['error'])) {
				if (empty($payload['name']))   $payload['name']   = $cbdata['name']       ?? null;
				if (empty($payload['qth']))    $payload['qth']    = $cbdata['city']        ?? null;
				if (!empty($cbdata['gridsquare'])) $payload['grid']   = $cbdata['gridsquare'];
				if (empty($payload['ituz']))   $payload['ituz']   = $cbdata['ituz']        ?? null;
				if (!empty($cbdata['dxcc'])) $payload['dxcc'] = $cbdata['dxcc'];
				if (!empty($cbdata['cqz']))  $payload['cqz']  = $cbdata['cqz'];
				$payload['source'] = $cbdata['source'] ?? null;
			}
		}

		header('Content-Type: application/json');
		echo json_encode($payload);
	}

	private function _parseExchangeFields($json) {
		$allowed = ['serial', 'gridsquare', 'exchange'];
		$decoded = json_decode($json ?? '', true);
		if (!is_array($decoded)) {
			return ['exchange'];
		}
		$fields = array_values(array_filter($decoded, fn($f) => in_array($f, $allowed)));
		return $fields ?: ['exchange'];
	}

	/**
	 * Normalise the serial number scope of a contest session.
	 *
	 * @param string|null $scope Raw POST value.
	 * @return string Either 'station' or 'operator'.
	 */
	private function _parseSerialScope($scope) {
		return in_array($scope, ['station', 'operator'], true) ? $scope : 'station';
	}

	private function _fieldsToLegacyType($fields) {
		$s = in_array('serial',    $fields);
		$g = in_array('gridsquare', $fields);
		$e = in_array('exchange',  $fields);
		if ($s && $g && $e) return 'SerialGridExchange';
		if ($s && $g)       return 'Serialgridsquare';
		if ($s && $e)       return 'Serialexchange';
		if ($e && $g)       return 'Exchangegridsquare';
		if ($s)             return 'Serial';
		return 'Exchange';
	}

	/**
	 * Validate and normalise a contest callsign.
	 *
	 * Mirrors the client-side validation in qso-form.js: only A-Z, 0-9 and the
	 * special character "/" are permitted. The "?" wildcard is allowed
	 * while searching in the UI but must be resolved before a QSO is saved, so a
	 * callsign still containing it is rejected here.
	 *
	 * @param string $callsign
	 * @return string normalised callsign (uppercase, Ø→0)
	 * @throws Exception if the callsign is empty or contains illegal characters
	 */
	private function _validateCallsign($callsign) {
		$call = str_replace('Ø', '0', strtoupper(trim((string)$callsign)));
		if ($call === '' || !preg_match('/^[A-Z0-9\/]+$/', $call)) {
			throw new Exception('Invalid callsign');
		}
		return $call;
	}

	private function _teapot() {
		http_response_code(418);
		echo json_encode(['success' => false, 'error' => "I'm a teapot. Don't brew coffee with me."]);
	}
}
