package kst4contest.controller;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember;
import kst4contest.utils.ApplicationFileUtils;

import java.util.EnumMap;
import java.util.HashSet;
import java.util.Set;

import kst4contest.model.Band;

public class DBController {

	/**
	 * Name of the database file.
	 */
	public static final String DATABASE_FILE = "praktiKST.db";

	/**
	 * Resource path for the database.
	 */
	public static final String DATABASE_RESOURCE = "/praktiKST.db";

	/**
	 * Number of milliseconds after which worked/not-QRV data is considered outdated
	 * and therefore automatically reset.
	 */
	private static final long WORKED_DATA_EXPIRATION_IN_MILLISECONDS = 65L * 60L * 60L * 1000L;

	/**
	 * Database schema version that includes the raw-callsign normalization migration
	 * marker. The marker is stored in SQLite PRAGMA user_version so the expensive
	 * normalization rebuild is executed only once per database file.
	 */
	private static final int CURRENT_DATABASE_SCHEMA_VERSION = 14;

	/**
	 * Minimum interval between two expiration cleanup runs. This avoids repeated full
	 * UPDATE statements during program startup when several database reads happen
	 * shortly after each other.
	 */
	private static final long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L;

	private static final DBController dbcontroller = new DBController();
	private static Connection connection;
	private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);

	/**
	 * Remembers the last timestamp at which the expiration cleanup had been executed.
	 */
	private long lastExpirationCleanupExecutionEpochMs = 0L;

	public DBController() {
		initDBConnection();
	}

	public static DBController getInstance() {
		return dbcontroller;
	}

	/**
	 * Closes the database connection if it is still open.
	 */
	public synchronized void closeDBConnection() {
		try {
			if (connection != null && !connection.isClosed()) {
				connection.close();
			}
		} catch (SQLException e) {
			e.printStackTrace();
		}
	}

	/**
	 * Initializes the SQLite connection and ensures that an existing legacy database
	 * is upgraded before normal runtime access starts.
	 */
	private synchronized void initDBConnection() {

		System.out.println("DBH: initiate new db connection");

		try {
			ApplicationFileUtils.copyResourceIfRequired(
					ApplicationConstants.APPLICATION_NAME,
					DATABASE_RESOURCE,
					DATABASE_FILE
			);

			if (connection != null && !connection.isClosed()) {
				return;
			}

			System.out.println("Creating Connection to Database...");

			DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
			connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH);

			System.out.println("[DBH, Info]: Path = " + DB_PATH);

			if (!connection.isClosed()) {
				System.out.println("...Connection established");
			}
		} catch (SQLException e) {
			throw new RuntimeException(e);
		}

		Runtime.getRuntime().addShutdownHook(new Thread() {
			public void run() {
				try {
					if (connection != null && !connection.isClosed()) {
						connection.close();

						if (connection.isClosed()) {
							System.out.println("Connection to Database closed");
						}
					}
				} catch (SQLException e) {
					e.printStackTrace();
				}
			}
		});

		ensureChatMemberTableCompatibility();
	}

	/**
	 * Ensures that the ChatMember table exists, that all required columns are
	 * available for newer software versions, that existing old callsign keys are
	 * normalized to callsignRaw semantics only once per database file and that
	 * outdated worked data is removed.
	 */
	private synchronized void ensureChatMemberTableCompatibility() {
		createChatMemberTableIfRequired();
		createWorkedGrossFieldTableIfRequired();
		versionUpdateOfDBCheckAndChangeV11ToV12();
		versionUpdateOfDBCheckAndChangeV12ToV13();

		if (helper_isDatabaseSchemaVersionOlderThanCurrent() || helper_isCallsignNormalizationMigrationRequired()) {
			normalizeStoredCallsignsToRawCallsigns();
			helper_writeCurrentDatabaseSchemaVersion();
		}

		resetExpiredWorkedDataIfRequired();
	}

	/**
	 * Checks whether the SQLite user_version marker is still older than the schema
	 * version expected by the current software release.
	 *
	 * @return true if the database should still be migrated to the current schema
	 */
	private synchronized boolean helper_isDatabaseSchemaVersionOlderThanCurrent() {

		try (Statement statement = connection.createStatement();
			 ResultSet resultSet = statement.executeQuery("PRAGMA user_version;")) {

			if (resultSet.next()) {
				return resultSet.getInt(1) < CURRENT_DATABASE_SCHEMA_VERSION;
			}
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not read database schema version", e);
		}

		return true;
	}

	/**
	 * Writes the current software schema version into SQLite PRAGMA user_version. The
	 * marker is used to avoid executing the expensive raw-callsign normalization on
	 * every application start.
	 */
	private synchronized void helper_writeCurrentDatabaseSchemaVersion() {

		try (Statement statement = connection.createStatement()) {
			statement.execute("PRAGMA user_version = " + CURRENT_DATABASE_SCHEMA_VERSION + ";");
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not write database schema version", e);
		}
	}

	/**
	 * Performs a cheap pre-check whether legacy callsign keys are still present in the
	 * database. The expensive full-table rebuild is only required if rows still
	 * contain slash or dash decorations like "EA5/G8MBI/P" or "OK2M-70".
	 *
	 * @return true if a normalization rebuild is still required
	 */
	private synchronized boolean helper_isCallsignNormalizationMigrationRequired() {

		String detectLegacyCallsignSql =
				"SELECT 1 FROM ChatMember WHERE callsign LIKE '%/%' OR callsign LIKE '%-%' LIMIT 1;";

		try (Statement statement = connection.createStatement();
			 ResultSet resultSet = statement.executeQuery(detectLegacyCallsignSql)) {

			return resultSet.next();
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not detect legacy callsign rows", e);
		}
	}

	/**
	 * Creates the ChatMember table if it does not exist yet. This keeps the program
	 * robust even if the resource database was missing or replaced by a user.
	 */
	private synchronized void createChatMemberTableIfRequired() {

		String createTableSql =
				"CREATE TABLE IF NOT EXISTS ChatMember ("
						+ "callsign TEXT NOT NULL PRIMARY KEY, "
						+ "qra TEXT, "
						+ "name TEXT, "
						+ "lastActivityDateTime TEXT, "
						+ "worked BOOLEAN DEFAULT 0, "
						+ "worked144 BOOLEAN DEFAULT 0, "
						+ "worked432 BOOLEAN DEFAULT 0, "
						+ "worked1240 BOOLEAN DEFAULT 0, "
						+ "worked2300 BOOLEAN DEFAULT 0, "
						+ "worked3400 BOOLEAN DEFAULT 0, "
						+ "worked5600 BOOLEAN DEFAULT 0, "
						+ "worked10G BOOLEAN DEFAULT 0, "
						+ "notQRV144 BOOLEAN DEFAULT 0, "
						+ "notQRV432 BOOLEAN DEFAULT 0, "
						+ "notQRV1240 BOOLEAN DEFAULT 0, "
						+ "notQRV2300 BOOLEAN DEFAULT 0, "
						+ "notQRV3400 BOOLEAN DEFAULT 0, "
						+ "notQRV5600 BOOLEAN DEFAULT 0, "
						+ "notQRV10G BOOLEAN DEFAULT 0, "
						+ "lastFlagsChangeEpochMs INTEGER DEFAULT 0"
						+ ");";

		try (Statement statement = connection.createStatement()) {
			statement.executeUpdate(createTableSql);
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not create ChatMember table", e);
		}
	}

	/**
	 * Creates the persistent gross-field table used by the new-locator filter.
	 * The primary key is band + gross field because the filter only needs to know
	 * whether a large locator square has already been worked on a band.
	 */
	private synchronized void createWorkedGrossFieldTableIfRequired() {

		String createTableSql =
				"CREATE TABLE IF NOT EXISTS WorkedGrossField ("
						+ "band TEXT NOT NULL, "
						+ "grossField TEXT NOT NULL, "
						+ "locator TEXT, "
						+ "callsign TEXT, "
						+ "source TEXT, "
						+ "lastWorkedEpochMs INTEGER NOT NULL, "
						+ "PRIMARY KEY (band, grossField)"
						+ ");";

		try (Statement statement = connection.createStatement()) {
			statement.executeUpdate(createTableSql);
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not create WorkedGrossField table", e);
		}
	}
	/**
	 * Updates old v1.1 databases to the v1.2 schema by adding the not-QRV fields if
	 * they are missing.
	 */
	public synchronized void versionUpdateOfDBCheckAndChangeV11ToV12() {

		try {
			ensureColumnExists("ChatMember", "notQRV144", "BOOLEAN DEFAULT 0");
			ensureColumnExists("ChatMember", "notQRV432", "BOOLEAN DEFAULT 0");
			ensureColumnExists("ChatMember", "notQRV1240", "BOOLEAN DEFAULT 0");
			ensureColumnExists("ChatMember", "notQRV2300", "BOOLEAN DEFAULT 0");
			ensureColumnExists("ChatMember", "notQRV3400", "BOOLEAN DEFAULT 0");
			ensureColumnExists("ChatMember", "notQRV5600", "BOOLEAN DEFAULT 0");
			ensureColumnExists("ChatMember", "notQRV10G", "BOOLEAN DEFAULT 0");
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not migrate database from v1.1 to v1.2", e);
		}
	}

	/**
	 * Updates old v1.2 databases to the v1.3 schema by adding a timestamp column
	 * which is used for automatic worked-data expiration.
	 */
	public synchronized void versionUpdateOfDBCheckAndChangeV12ToV13() {

		try {
			ensureColumnExists("ChatMember", "lastFlagsChangeEpochMs", "INTEGER DEFAULT 0");
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not migrate database from v1.2 to v1.3", e);
		}
	}

	/**
	 * Adds a missing column to an existing table. This method is used for safe schema
	 * upgrades on customer systems which still contain older database files.
	 *
	 * @param tableName        table to inspect
	 * @param columnName       column which must exist
	 * @param columnDefinition SQL definition used for ALTER TABLE
	 * @throws SQLException if the metadata lookup or ALTER TABLE fails
	 */
	private synchronized void ensureColumnExists(String tableName, String columnName, String columnDefinition) throws SQLException {

		if (helper_checkIfColumnExists(tableName, columnName)) {
			return;
		}

		System.out.println("DBH, Info: adding missing column " + columnName + " to table " + tableName);

		try (Statement statement = connection.createStatement()) {
			statement.executeUpdate("ALTER TABLE " + tableName + " ADD " + columnName + " " + columnDefinition + ";");
		}
	}

	/**
	 * Checks via PRAGMA metadata whether a certain column is already available in the
	 * database.
	 *
	 * @param tableName  table to inspect
	 * @param columnName column to look for
	 * @return true if the column exists already
	 * @throws SQLException if the metadata query fails
	 */
	private synchronized boolean helper_checkIfColumnExists(String tableName, String columnName) throws SQLException {

		try (Statement statement = connection.createStatement();
			 ResultSet resultSet = statement.executeQuery("PRAGMA table_info(" + tableName + ");")) {

			while (resultSet.next()) {
				if (columnName.equalsIgnoreCase(resultSet.getString("name"))) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Rebuilds the ChatMember table so that every stored row uses the normalized raw
	 * callsign as primary key. Old keys like "EA5/G8MBI/P" or "OK2M-70" are merged to
	 * "G8MBI" and "OK2M". This prevents duplicate logical stations and fixes legacy
	 * databases created by earlier software versions. The rebuild is executed inside
	 * one transaction so startup remains fast even for a few thousand rows.
	 */
	private synchronized void normalizeStoredCallsignsToRawCallsigns() {

		if (!helper_isCallsignNormalizationMigrationRequired()) {
			return;
		}

		Map<String, ChatMember> normalizedChatMembersByRawCallsign = new LinkedHashMap<>();

		try (Statement statement = connection.createStatement();
			 ResultSet resultSet = statement.executeQuery("SELECT * FROM ChatMember ORDER BY callsign ASC;")) {

			while (resultSet.next()) {
				ChatMember currentChatMemberFromDatabase = helper_buildChatMemberFromResultSet(resultSet);
				String currentRawCallsign = currentChatMemberFromDatabase.getCallSignRaw();

				if (currentRawCallsign == null || currentRawCallsign.isBlank()) {
					continue;
				}

				ChatMember existingNormalizedChatMember = normalizedChatMembersByRawCallsign.get(currentRawCallsign);

				if (existingNormalizedChatMember == null) {
					normalizedChatMembersByRawCallsign.put(currentRawCallsign, currentChatMemberFromDatabase);
				} else {
					helper_mergeChatMemberDatabaseState(existingNormalizedChatMember, currentChatMemberFromDatabase);
				}
			}
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not normalize stored callsigns", e);
		}

		boolean originalAutoCommitState;

		try {
			originalAutoCommitState = connection.getAutoCommit();
			connection.setAutoCommit(false);

			try (Statement deleteStatement = connection.createStatement()) {
				deleteStatement.executeUpdate("DELETE FROM ChatMember;");
			}

			for (ChatMember normalizedChatMember : normalizedChatMembersByRawCallsign.values()) {
				helper_upsertCompleteChatMemberRow(normalizedChatMember);
			}

			connection.commit();
			connection.setAutoCommit(originalAutoCommitState);
		} catch (SQLException e) {
			try {
				connection.rollback();
			} catch (SQLException rollbackException) {
				rollbackException.printStackTrace();
			}

			try {
				connection.setAutoCommit(true);
			} catch (SQLException autoCommitResetException) {
				autoCommitResetException.printStackTrace();
			}

			throw new RuntimeException("[DBH, ERROR:] Could not rebuild ChatMember table with normalized raw callsigns", e);
		}
	}

	/**
	 * Merges the database state of two rows that represent the same normalized raw
	 * callsign. Worked and not-QRV information is combined conservatively via logical
	 * OR so that no positive state is lost during migration.
	 *
	 * @param targetChatMember target row that remains after merge
	 * @param sourceChatMember source row that is merged into the target row
	 */
	private synchronized void helper_mergeChatMemberDatabaseState(ChatMember targetChatMember, ChatMember sourceChatMember) {

		if ((targetChatMember.getQra() == null || targetChatMember.getQra().isBlank())
				&& sourceChatMember.getQra() != null && !sourceChatMember.getQra().isBlank()) {
			targetChatMember.setQra(sourceChatMember.getQra());
		}

		if ((targetChatMember.getName() == null || targetChatMember.getName().isBlank())
				&& sourceChatMember.getName() != null && !sourceChatMember.getName().isBlank()) {
			targetChatMember.setName(sourceChatMember.getName());
		}

		if (targetChatMember.getLastActivity() == null && sourceChatMember.getLastActivity() != null) {
			targetChatMember.setLastActivity(sourceChatMember.getLastActivity());
		}

		targetChatMember.setWorked(targetChatMember.isWorked() || sourceChatMember.isWorked());
		targetChatMember.setWorked144(targetChatMember.isWorked144() || sourceChatMember.isWorked144());
		targetChatMember.setWorked432(targetChatMember.isWorked432() || sourceChatMember.isWorked432());
		targetChatMember.setWorked1240(targetChatMember.isWorked1240() || sourceChatMember.isWorked1240());
		targetChatMember.setWorked2300(targetChatMember.isWorked2300() || sourceChatMember.isWorked2300());
		targetChatMember.setWorked3400(targetChatMember.isWorked3400() || sourceChatMember.isWorked3400());
		targetChatMember.setWorked5600(targetChatMember.isWorked5600() || sourceChatMember.isWorked5600());
		targetChatMember.setWorked10G(targetChatMember.isWorked10G() || sourceChatMember.isWorked10G());

		targetChatMember.setQrv144(targetChatMember.isQrv144() && sourceChatMember.isQrv144());
		targetChatMember.setQrv432(targetChatMember.isQrv432() && sourceChatMember.isQrv432());
		targetChatMember.setQrv1240(targetChatMember.isQrv1240() && sourceChatMember.isQrv1240());
		targetChatMember.setQrv2300(targetChatMember.isQrv2300() && sourceChatMember.isQrv2300());
		targetChatMember.setQrv3400(targetChatMember.isQrv3400() && sourceChatMember.isQrv3400());
		targetChatMember.setQrv5600(targetChatMember.isQrv5600() && sourceChatMember.isQrv5600());
		targetChatMember.setQrv10G(targetChatMember.isQrv10G() && sourceChatMember.isQrv10G());

		targetChatMember.setLastFlagsChangeEpochMs(
				Math.max(targetChatMember.getLastFlagsChangeEpochMs(), sourceChatMember.getLastFlagsChangeEpochMs()));
	}

	/**
	 * Removes outdated worked and not-QRV flags if their last change timestamp is
	 * older than the configured contest lifetime. The cleanup is throttled so that it
	 * cannot run multiple times within a few seconds during application startup.
	 */
	public synchronized void resetExpiredWorkedDataIfRequired() {

		long currentEpochMilliseconds = System.currentTimeMillis();

		if (currentEpochMilliseconds - lastExpirationCleanupExecutionEpochMs
				< EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS) {
			return;
		}

		long expirationThresholdEpochMs = currentEpochMilliseconds - WORKED_DATA_EXPIRATION_IN_MILLISECONDS;

		String resetExpiredDataSql =
				"UPDATE ChatMember SET "
						+ "worked = 0, "
						+ "worked144 = 0, "
						+ "worked432 = 0, "
						+ "worked1240 = 0, "
						+ "worked2300 = 0, "
						+ "worked3400 = 0, "
						+ "worked5600 = 0, "
						+ "worked10G = 0, "
						+ "notQRV144 = 0, "
						+ "notQRV432 = 0, "
						+ "notQRV1240 = 0, "
						+ "notQRV2300 = 0, "
						+ "notQRV3400 = 0, "
						+ "notQRV5600 = 0, "
						+ "notQRV10G = 0, "
						+ "lastFlagsChangeEpochMs = 0 "
						+ "WHERE lastFlagsChangeEpochMs > 0 AND lastFlagsChangeEpochMs < ?;";

		try (PreparedStatement preparedStatement = connection.prepareStatement(resetExpiredDataSql)) {
			preparedStatement.setLong(1, expirationThresholdEpochMs);
			preparedStatement.executeUpdate();
			lastExpirationCleanupExecutionEpochMs = currentEpochMilliseconds;
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not reset expired worked data", e);
		}

		try (PreparedStatement deleteGrossFieldsStatement = connection.prepareStatement(
				"DELETE FROM WorkedGrossField WHERE lastWorkedEpochMs > 0 AND lastWorkedEpochMs < ?;")) {
			deleteGrossFieldsStatement.setLong(1, expirationThresholdEpochMs);
			deleteGrossFieldsStatement.executeUpdate();
		} catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

	/**
	 * Stores a chatmember with its metadata in the database. The unique key is always
	 * the normalized raw callsign. Existing worked/not-QRV flags are preserved on
	 * conflicts so that a normal member refresh does not delete contest state.
	 *
	 * @param chatMemberToStore chatmember to insert or update
	 * @throws SQLException if the database write fails
	 */
	public synchronized void storeChatMember(ChatMember chatMemberToStore) throws SQLException {

		if (chatMemberToStore == null || chatMemberToStore.getCallSignRaw() == null || chatMemberToStore.getCallSignRaw().isBlank()) {
			return;
		}

		String insertOrUpdateSql =
				"INSERT INTO ChatMember ("
						+ "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, "
						+ "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, lastFlagsChangeEpochMs"
						+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
						+ "ON CONFLICT(callsign) DO UPDATE SET "
						+ "qra = excluded.qra, "
						+ "name = excluded.name, "
						+ "lastActivityDateTime = excluded.lastActivityDateTime;";

		long resolvedLastFlagsChangeEpochMs = helper_resolveLastFlagsChangeEpochMsForStore(chatMemberToStore);

		try (PreparedStatement preparedStatement = connection.prepareStatement(insertOrUpdateSql)) {
			preparedStatement.setString(1, chatMemberToStore.getCallSignRaw());
			preparedStatement.setString(2, chatMemberToStore.getQra());
			preparedStatement.setString(3, chatMemberToStore.getName());
			preparedStatement.setString(4, chatMemberToStore.getLastActivity() == null ? null : chatMemberToStore.getLastActivity().toString());
			preparedStatement.setInt(5, helper_booleanIntConverter(chatMemberToStore.isWorked()));
			preparedStatement.setInt(6, helper_booleanIntConverter(chatMemberToStore.isWorked144()));
			preparedStatement.setInt(7, helper_booleanIntConverter(chatMemberToStore.isWorked432()));
			preparedStatement.setInt(8, helper_booleanIntConverter(chatMemberToStore.isWorked1240()));
			preparedStatement.setInt(9, helper_booleanIntConverter(chatMemberToStore.isWorked2300()));
			preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400()));
			preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600()));
			preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G()));
			preparedStatement.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
			preparedStatement.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
			preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
			preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
			preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
			preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
			preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
			preparedStatement.setLong(20, resolvedLastFlagsChangeEpochMs);
			preparedStatement.executeUpdate();
		} catch (SQLException e) {
			System.err.println("[DBH, ERROR:] Chatmember could not been stored.");
			e.printStackTrace();
			throw e;
		}
	}

	/**
	 * Fetches all stored chatmember rows from the database and returns them in a map
	 * keyed by the normalized raw callsign.
	 *
	 * @return map of raw callsign to chatmember database state
	 * @throws SQLException if the database read fails
	 */
	public synchronized HashMap<String, ChatMember> fetchChatMemberWkdDataFromDB() throws SQLException {

		resetExpiredWorkedDataIfRequired();

		HashMap<String, ChatMember> fetchedWorkedData = new LinkedHashMap<>();

		try (Statement statement = connection.createStatement();
			 ResultSet resultSet = statement.executeQuery("SELECT * FROM ChatMember ORDER BY callsign ASC;")) {

			while (resultSet.next()) {
				ChatMember updatedWorkedData = helper_buildChatMemberFromResultSet(resultSet);
				fetchedWorkedData.put(updatedWorkedData.getCallSignRaw(), updatedWorkedData);
			}

			return fetchedWorkedData;
		} catch (SQLException e) {
			System.err.println("[DBH, ERROR:] Couldn't handle DB-Query");
			e.printStackTrace();
			throw e;
		}
	}

	/**
	 * Fetches the worked and not-QRV state for a single chatmember from the database.
	 * The lookup is always performed with the normalized raw callsign.
	 *
	 * @param checkForThis chatmember instance that should receive the stored flags
	 * @return the same chatmember instance with updated flags
	 */
	public synchronized ChatMember fetchChatMemberWkdDataForOnlyOneCallsignFromDB(ChatMember checkForThis) {

		if (checkForThis == null || checkForThis.getCallSignRaw() == null || checkForThis.getCallSignRaw().isBlank()) {
			return checkForThis;
		}

		resetExpiredWorkedDataIfRequired();

		String selectSingleChatMemberSql = "SELECT * FROM ChatMember WHERE callsign = ?;";

		try (PreparedStatement preparedStatement = connection.prepareStatement(selectSingleChatMemberSql)) {
			preparedStatement.setString(1, checkForThis.getCallSignRaw());

			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				if (resultSet.next()) {
					helper_copyWorkedAndQrvFlags(helper_buildChatMemberFromResultSet(resultSet), checkForThis);
				}
			}

			return checkForThis;
		} catch (SQLException e) {
			System.err.println("[DBH, ERROR:] Couldn't handle DB-Query");
			e.printStackTrace();
			return checkForThis;
		}
	}

	/**
	 * Removes all worked and not-QRV information from the database. The callsign rows
	 * remain in place so that the table view still shows known stations afterwards.
	 *
	 * @return number of affected database rows, or -1 on error
	 */
	public synchronized int resetWorkedDataInDB() {

		String resetAllWorkedDataSql =
				"UPDATE ChatMember SET "
						+ "worked = 0, worked144 = 0, worked432 = 0, worked1240 = 0, worked2300 = 0, worked3400 = 0, worked5600 = 0, worked10G = 0, "
						+ "notQRV144 = 0, notQRV432 = 0, notQRV1240 = 0, notQRV2300 = 0, notQRV3400 = 0, notQRV5600 = 0, notQRV10G = 0, "
						+ "lastFlagsChangeEpochMs = 0;";

		try (Statement statement = connection.createStatement()) {
//			return statement.executeUpdate(resetAllWorkedDataSql);
			int affectedRows = statement.executeUpdate(resetAllWorkedDataSql);
			statement.executeUpdate("DELETE FROM WorkedGrossField;");
			return affectedRows;


		} catch (SQLException e) {

			System.err.println("[DBH, ERROR:] Couldn't reset the worked data");
			e.printStackTrace();
			return -1;
		}
	}

	/**
	 * Updates the worked-information of a chatmember row. The method only succeeds if
	 * a row with the normalized raw callsign exists already.
	 *
	 * @param chatMemberToStore chatmember that contains the worked band information
	 * @return true if an existing row was updated, otherwise false
	 * @throws SQLException if the database write fails
	 */
	public synchronized boolean updateWkdInfoOnChatMember(ChatMember chatMemberToStore) throws SQLException {

		if (chatMemberToStore == null || chatMemberToStore.getCallSignRaw() == null || chatMemberToStore.getCallSignRaw().isBlank()) {
			return false;
		}

		String workedBandColumnName = helper_resolveWorkedBandColumnName(chatMemberToStore);

		if (workedBandColumnName == null) {
			return helper_updateWorkedAnyOnChatMember(chatMemberToStore);
		}

//		String updateWorkedSql =
//				"UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;";

		String updateWorkedSql =
				"UPDATE ChatMember SET worked = 1, "
						+ workedBandColumnName
						+ " = 1, "
						+ "qra = CASE WHEN ? IS NOT NULL AND TRIM(?) <> '' AND LOWER(TRIM(?)) <> 'unknown' THEN ? ELSE qra END, "
						+ "lastFlagsChangeEpochMs = ? WHERE callsign = ?;";

		try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedSql)) {
//			preparedStatement.setLong(1, System.currentTimeMillis());
//			preparedStatement.setString(2, chatMemberToStore.getCallSignRaw());

			String qra = chatMemberToStore.getQra();
			preparedStatement.setString(1, qra);
			preparedStatement.setString(2, qra);
			preparedStatement.setString(3, qra);
			preparedStatement.setString(4, qra);
			preparedStatement.setLong(5, System.currentTimeMillis());
			preparedStatement.setString(6, chatMemberToStore.getCallSignRaw());

			int affectedRows = preparedStatement.executeUpdate();
			return affectedRows > 0;
		} catch (SQLException e) {
			System.err.println("[DBH, ERROR:] Couldn't handle DB-Query");
			e.printStackTrace();
			throw e;
		}
	}

	/**
	 * Updates only the global worked-any flag of a stored chatmember row.
	 *
	 * <p>This is used when an external logger confirms that a station was worked,
	 * but the software cannot reliably map the QSO to one of the persisted band
	 * columns. The UI status "x" is based on this global worked flag, so this method
	 * makes no-band or unsupported-band log entries persistent as worked-any.</p>
	 *
	 * @param chatMemberToStore chatmember that contains the worked call and optional locator
	 * @return true if an existing database row was updated
	 * @throws SQLException if the database write fails
	 */
	private synchronized boolean helper_updateWorkedAnyOnChatMember(ChatMember chatMemberToStore) throws SQLException {

		if (chatMemberToStore == null
				|| chatMemberToStore.getCallSignRaw() == null
				|| chatMemberToStore.getCallSignRaw().isBlank()) {
			return false;
		}

		String updateWorkedAnySql =
				"UPDATE ChatMember SET worked = 1, "
						+ "qra = CASE WHEN ? IS NOT NULL AND TRIM(?) <> '' AND LOWER(TRIM(?)) <> 'unknown' THEN ? ELSE qra END, "
						+ "lastFlagsChangeEpochMs = ? WHERE callsign = ?;";

		try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedAnySql)) {
			String qra = chatMemberToStore.getQra();

			preparedStatement.setString(1, qra);
			preparedStatement.setString(2, qra);
			preparedStatement.setString(3, qra);
			preparedStatement.setString(4, qra);
			preparedStatement.setLong(5, System.currentTimeMillis());
			preparedStatement.setString(6, chatMemberToStore.getCallSignRaw());

			int affectedRows = preparedStatement.executeUpdate();
			return affectedRows > 0;
		}
	}


	/**
	 * Updates all not-QRV flags for a chatmember row. The method uses the normalized
	 * raw callsign and updates the timestamp so that automatic contest cleanup can
	 * reset the flags later.
	 *
	 * @param chatMemberToStore chatmember with the not-QRV information to persist
	 * @return true if an existing row was updated or inserted
	 * @throws SQLException if the database write fails
	 */
	public synchronized boolean updateNotQRVInfoOnChatMember(ChatMember chatMemberToStore) throws SQLException {

		if (chatMemberToStore == null || chatMemberToStore.getCallSignRaw() == null || chatMemberToStore.getCallSignRaw().isBlank()) {
			return false;
		}

		String updateNotQrvSql =
				"UPDATE ChatMember SET "
						+ "notQRV144 = ?, "
						+ "notQRV432 = ?, "
						+ "notQRV1240 = ?, "
						+ "notQRV2300 = ?, "
						+ "notQRV3400 = ?, "
						+ "notQRV5600 = ?, "
						+ "notQRV10G = ?, "
						+ "lastFlagsChangeEpochMs = ? "
						+ "WHERE callsign = ?;";

		try (PreparedStatement preparedStatement = connection.prepareStatement(updateNotQrvSql)) {
			preparedStatement.setInt(1, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
			preparedStatement.setInt(2, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
			preparedStatement.setInt(3, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
			preparedStatement.setInt(4, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
			preparedStatement.setInt(5, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
			preparedStatement.setInt(6, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
			preparedStatement.setInt(7, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
			preparedStatement.setLong(8, System.currentTimeMillis());
			preparedStatement.setString(9, chatMemberToStore.getCallSignRaw());

			int affectedRows = preparedStatement.executeUpdate();

			if (affectedRows == 0) {
				chatMemberToStore.setLastFlagsChangeEpochMs(System.currentTimeMillis());
				storeChatMember(chatMemberToStore);
			}

			return true;
		} catch (SQLException e) {
			System.err.println("[DBH, ERROR:] Couldn't handle DB-Query");
			e.printStackTrace();
			throw e;
		}
	}

	/**
	 * Writes a complete ChatMember row including worked state and timestamp back into
	 * the database. This helper is used during migration when the complete table is
	 * rebuilt with normalized raw callsign keys.
	 *
	 * @param chatMemberToStore chatmember state to write completely
	 */
	private synchronized void helper_upsertCompleteChatMemberRow(ChatMember chatMemberToStore) {

		String upsertCompleteRowSql =
				"INSERT INTO ChatMember ("
						+ "callsign, qra, name, lastActivityDateTime, worked, worked144, worked432, worked1240, worked2300, worked3400, worked5600, worked10G, "
						+ "notQRV144, notQRV432, notQRV1240, notQRV2300, notQRV3400, notQRV5600, notQRV10G, lastFlagsChangeEpochMs"
						+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
						+ "ON CONFLICT(callsign) DO UPDATE SET "
						+ "qra = excluded.qra, "
						+ "name = excluded.name, "
						+ "lastActivityDateTime = excluded.lastActivityDateTime, "
						+ "worked = excluded.worked, "
						+ "worked144 = excluded.worked144, "
						+ "worked432 = excluded.worked432, "
						+ "worked1240 = excluded.worked1240, "
						+ "worked2300 = excluded.worked2300, "
						+ "worked3400 = excluded.worked3400, "
						+ "worked5600 = excluded.worked5600, "
						+ "worked10G = excluded.worked10G, "
						+ "notQRV144 = excluded.notQRV144, "
						+ "notQRV432 = excluded.notQRV432, "
						+ "notQRV1240 = excluded.notQRV1240, "
						+ "notQRV2300 = excluded.notQRV2300, "
						+ "notQRV3400 = excluded.notQRV3400, "
						+ "notQRV5600 = excluded.notQRV5600, "
						+ "notQRV10G = excluded.notQRV10G, "
						+ "lastFlagsChangeEpochMs = excluded.lastFlagsChangeEpochMs;";

		try (PreparedStatement preparedStatement = connection.prepareStatement(upsertCompleteRowSql)) {
			preparedStatement.setString(1, chatMemberToStore.getCallSignRaw());
			preparedStatement.setString(2, chatMemberToStore.getQra());
			preparedStatement.setString(3, chatMemberToStore.getName());
			preparedStatement.setString(4, chatMemberToStore.getLastActivity() == null ? null : chatMemberToStore.getLastActivity().toString());
			preparedStatement.setInt(5, helper_booleanIntConverter(chatMemberToStore.isWorked()));
			preparedStatement.setInt(6, helper_booleanIntConverter(chatMemberToStore.isWorked144()));
			preparedStatement.setInt(7, helper_booleanIntConverter(chatMemberToStore.isWorked432()));
			preparedStatement.setInt(8, helper_booleanIntConverter(chatMemberToStore.isWorked1240()));
			preparedStatement.setInt(9, helper_booleanIntConverter(chatMemberToStore.isWorked2300()));
			preparedStatement.setInt(10, helper_booleanIntConverter(chatMemberToStore.isWorked3400()));
			preparedStatement.setInt(11, helper_booleanIntConverter(chatMemberToStore.isWorked5600()));
			preparedStatement.setInt(12, helper_booleanIntConverter(chatMemberToStore.isWorked10G()));
			preparedStatement.setInt(13, helper_booleanIntConverter(!chatMemberToStore.isQrv144()));
			preparedStatement.setInt(14, helper_booleanIntConverter(!chatMemberToStore.isQrv432()));
			preparedStatement.setInt(15, helper_booleanIntConverter(!chatMemberToStore.isQrv1240()));
			preparedStatement.setInt(16, helper_booleanIntConverter(!chatMemberToStore.isQrv2300()));
			preparedStatement.setInt(17, helper_booleanIntConverter(!chatMemberToStore.isQrv3400()));
			preparedStatement.setInt(18, helper_booleanIntConverter(!chatMemberToStore.isQrv5600()));
			preparedStatement.setInt(19, helper_booleanIntConverter(!chatMemberToStore.isQrv10G()));
			preparedStatement.setLong(20, chatMemberToStore.getLastFlagsChangeEpochMs());
			preparedStatement.executeUpdate();
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not rebuild normalized ChatMember row", e);
		}
	}

	/**
	 * Builds a ChatMember object from a database result row.
	 *
	 * @param resultSet current database row
	 * @return chatmember filled with worked/not-QRV database state
	 * @throws SQLException if a column cannot be read
	 */
	private synchronized ChatMember helper_buildChatMemberFromResultSet(ResultSet resultSet) throws SQLException {

		ChatMember builtChatMember = new ChatMember();

		builtChatMember.setCallSign(resultSet.getString("callsign"));
		builtChatMember.setQra(resultSet.getString("qra"));
		builtChatMember.setName(resultSet.getString("name"));
		builtChatMember.setWorked(helper_IntToBooleanConverter(resultSet.getInt("worked")));
		builtChatMember.setWorked144(helper_IntToBooleanConverter(resultSet.getInt("worked144")));
		builtChatMember.setWorked432(helper_IntToBooleanConverter(resultSet.getInt("worked432")));
		builtChatMember.setWorked1240(helper_IntToBooleanConverter(resultSet.getInt("worked1240")));
		builtChatMember.setWorked2300(helper_IntToBooleanConverter(resultSet.getInt("worked2300")));
		builtChatMember.setWorked3400(helper_IntToBooleanConverter(resultSet.getInt("worked3400")));
		builtChatMember.setWorked5600(helper_IntToBooleanConverter(resultSet.getInt("worked5600")));
		builtChatMember.setWorked10G(helper_IntToBooleanConverter(resultSet.getInt("worked10G")));
		builtChatMember.setQrv144(!helper_IntToBooleanConverter(resultSet.getInt("notQRV144")));
		builtChatMember.setQrv432(!helper_IntToBooleanConverter(resultSet.getInt("notQRV432")));
		builtChatMember.setQrv1240(!helper_IntToBooleanConverter(resultSet.getInt("notQRV1240")));
		builtChatMember.setQrv2300(!helper_IntToBooleanConverter(resultSet.getInt("notQRV2300")));
		builtChatMember.setQrv3400(!helper_IntToBooleanConverter(resultSet.getInt("notQRV3400")));
		builtChatMember.setQrv5600(!helper_IntToBooleanConverter(resultSet.getInt("notQRV5600")));
		builtChatMember.setQrv10G(!helper_IntToBooleanConverter(resultSet.getInt("notQRV10G")));
		builtChatMember.setLastFlagsChangeEpochMs(resultSet.getLong("lastFlagsChangeEpochMs"));

		return builtChatMember;
	}

	/**
	 * Copies the worked and not-QRV state from one ChatMember object to another one.
	 *
	 * @param sourceChatMember source state
	 * @param targetChatMember target object that should receive the state
	 */
	private synchronized void helper_copyWorkedAndQrvFlags(ChatMember sourceChatMember, ChatMember targetChatMember) {

		targetChatMember.setWorked(sourceChatMember.isWorked());
		targetChatMember.setWorked144(sourceChatMember.isWorked144());
		targetChatMember.setWorked432(sourceChatMember.isWorked432());
		targetChatMember.setWorked1240(sourceChatMember.isWorked1240());
		targetChatMember.setWorked2300(sourceChatMember.isWorked2300());
		targetChatMember.setWorked3400(sourceChatMember.isWorked3400());
		targetChatMember.setWorked5600(sourceChatMember.isWorked5600());
		targetChatMember.setWorked10G(sourceChatMember.isWorked10G());
		targetChatMember.setQrv144(sourceChatMember.isQrv144());
		targetChatMember.setQrv432(sourceChatMember.isQrv432());
		targetChatMember.setQrv1240(sourceChatMember.isQrv1240());
		targetChatMember.setQrv2300(sourceChatMember.isQrv2300());
		targetChatMember.setQrv3400(sourceChatMember.isQrv3400());
		targetChatMember.setQrv5600(sourceChatMember.isQrv5600());
		targetChatMember.setQrv10G(sourceChatMember.isQrv10G());
		targetChatMember.setLastFlagsChangeEpochMs(sourceChatMember.getLastFlagsChangeEpochMs());
	}

	/**
	 * Determines which worked-band column must be updated for a given chatmember.
	 *
	 * @param chatMemberToStore worked chatmember update
	 * @return database column name, or null if no worked band is marked
	 */
	private synchronized String helper_resolveWorkedBandColumnName(ChatMember chatMemberToStore) {

		if (chatMemberToStore.isWorked144()) {
			return "worked144";
		} else if (chatMemberToStore.isWorked432()) {
			return "worked432";
		} else if (chatMemberToStore.isWorked1240()) {
			return "worked1240";
		} else if (chatMemberToStore.isWorked2300()) {
			return "worked2300";
		} else if (chatMemberToStore.isWorked3400()) {
			return "worked3400";
		} else if (chatMemberToStore.isWorked5600()) {
			return "worked5600";
		} else if (chatMemberToStore.isWorked10G()) {
			return "worked10G";
		}

		return null;
	}

	/**
	 * Resolves the timestamp that should be written into the timestamp column when a
	 * complete row is inserted. Normal member metadata rows keep the value 0, while
	 * rows with existing worked/not-QRV state receive the current timestamp.
	 *
	 * @param chatMemberToStore row that is about to be inserted
	 * @return timestamp to write into the database
	 */
	private synchronized long helper_resolveLastFlagsChangeEpochMsForStore(ChatMember chatMemberToStore) {

		if (chatMemberToStore.getLastFlagsChangeEpochMs() > 0) {
			return chatMemberToStore.getLastFlagsChangeEpochMs();
		}

		if (helper_hasAnyWorkedOrNotQrvState(chatMemberToStore)) {
			return System.currentTimeMillis();
		}

		return 0L;
	}

	/**
	 * Checks whether a ChatMember row currently contains any persisted worked or
	 * not-QRV information.
	 *
	 * @param chatMemberToStore chatmember to inspect
	 * @return true if any database-relevant state is set
	 */
	private synchronized boolean helper_hasAnyWorkedOrNotQrvState(ChatMember chatMemberToStore) {
		return chatMemberToStore.isWorked()
				|| chatMemberToStore.isWorked144()
				|| chatMemberToStore.isWorked432()
				|| chatMemberToStore.isWorked1240()
				|| chatMemberToStore.isWorked2300()
				|| chatMemberToStore.isWorked3400()
				|| chatMemberToStore.isWorked5600()
				|| chatMemberToStore.isWorked10G()
				|| !chatMemberToStore.isQrv144()
				|| !chatMemberToStore.isQrv432()
				|| !chatMemberToStore.isQrv1240()
				|| !chatMemberToStore.isQrv2300()
				|| !chatMemberToStore.isQrv3400()
				|| !chatMemberToStore.isQrv5600()
				|| !chatMemberToStore.isQrv10G();
	}

	/**
	 * Converts a boolean value into the integer representation used in the SQLite
	 * table.
	 *
	 * @param convertToInt boolean value
	 * @return 1 for true, 0 for false
	 */
	private int helper_booleanIntConverter(boolean convertToInt) {

		if (convertToInt) {
			return 1;
		} else {
			return 0;
		}
	}

	/**
	 * Converts the integer representation from SQLite into a boolean value.
	 *
	 * @param valueFromDBField integer value from the database
	 * @return true if the value is not zero
	 */
	private boolean helper_IntToBooleanConverter(int valueFromDBField) {

		if (valueFromDBField != 0) {
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Small manual test entry point for local experiments on the database helper.
	 *
	 * @param args CLI arguments
	 * @throws SQLException if a database operation fails
	 */
	public static void main(String[] args) throws SQLException {
		DBController dbc = DBController.getInstance();

		ChatMember dummy = new ChatMember();
		dummy.setCallSign("DM5M");
		dummy.setQra("jo51ij");
		dummy.setName("Team Test");
		dummy.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
		dummy.setWorked5600(true);

		// dbc.storeChatMember(dummy);
		// dbc.updateWkdInfoOnChatMember(dummy);
	}

	/**
	 * Inserts or updates a worked gross field for the new-locator filter.
	 *
	 * @param band worked band
	 * @param locator6 six-character Maidenhead locator
	 * @param callsignRaw normalized raw callsign or null
	 * @param source source identifier such as UCXLOG or WINTEST
	 */
	public synchronized void upsertWorkedGrossField(Band band, String locator6, String callsignRaw, String source) {

		String grossField = WorkedGrossFieldCache.extractGrossField(locator6);
		String normalizedLocator6 = WorkedGrossFieldCache.extractLocator6(locator6);

		if (band == null || grossField == null) {
			return;
		}

		String upsertSql =
				"INSERT INTO WorkedGrossField (band, grossField, locator, callsign, source, lastWorkedEpochMs) "
						+ "VALUES (?, ?, ?, ?, ?, ?) "
						+ "ON CONFLICT(band, grossField) DO UPDATE SET "
						+ "locator = COALESCE(excluded.locator, WorkedGrossField.locator), "
						+ "callsign = COALESCE(excluded.callsign, WorkedGrossField.callsign), "
						+ "source = excluded.source, "
						+ "lastWorkedEpochMs = excluded.lastWorkedEpochMs;";

		try (PreparedStatement preparedStatement = connection.prepareStatement(upsertSql)) {
			preparedStatement.setString(1, band.name());
			preparedStatement.setString(2, grossField);
			preparedStatement.setString(3, normalizedLocator6);
			preparedStatement.setString(4, callsignRaw);
			preparedStatement.setString(5, source == null ? "UNKNOWN" : source);
			preparedStatement.setLong(6, System.currentTimeMillis());
			preparedStatement.executeUpdate();
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not upsert worked gross field", e);
		}
	}

	/**
	 * Loads all non-expired worked gross fields from the database.
	 *
	 * @return map of band to worked gross fields
	 */
	public synchronized Map<Band, Set<String>> fetchWorkedGrossFieldsFromDB() {

		resetExpiredWorkedDataIfRequired();

		Map<Band, Set<String>> result = new EnumMap<>(Band.class);

		try (Statement statement = connection.createStatement();
		     ResultSet resultSet = statement.executeQuery("SELECT band, grossField FROM WorkedGrossField ORDER BY band, grossField;")) {

			while (resultSet.next()) {
				Band band;
				try {
					band = Band.valueOf(resultSet.getString("band"));
				} catch (Exception ignored) {
					continue;
				}

				String grossField = WorkedGrossFieldCache.extractGrossField(resultSet.getString("grossField"));
				if (grossField == null) {
					continue;
				}

				result.computeIfAbsent(band, ignored -> new HashSet<>()).add(grossField);
			}
		} catch (SQLException e) {
			throw new RuntimeException("[DBH, ERROR:] Could not fetch worked gross fields", e);
		}

		return result;
	}
}
