import { TransportAdapter } from './transport-adapter.js';

/**
 * WsTransport - WebSocket-backed TransportAdapter for the Wavelog Worker.
 *
 * Responsibilities:
 *   - Establish and maintain a WebSocket connection to the Worker
 *   - Authenticate on the assigned topic (HMAC token)
 *   - On incoming push: notify listeners so the SyncEngine can react immediately
 *
 * send() delegates to the provided AjaxTransport — the Worker is receive-only,
 * all writes still go through the Ajax heartbeat endpoint.
 *
 * Usage:
 *   const ws = new WsTransport(ajaxTransport, cfg.url, cfg.topic, cfg.token);
 *   ws.onPush = (payload) => syncEngine.triggerNow();
 *   ws.connect();
 */
export class WsTransport extends TransportAdapter {
	/**
	 * @param {TransportAdapter} ajaxTransport - Fallback for send()
	 * @param {string} url   - Worker base URL, e.g. "ws://localhost:9000"
	 * @param {string} topic - Topic to subscribe to, e.g. "contest_session.abc123"
	 * @param {string} token - HMAC auth token generated by PHP
	 */
	constructor(ajaxTransport, url, topic, token) {
		super();
		this._ajax   = ajaxTransport;
		this._url    = url.replace(/\/$/, '');
		this._topic  = topic;
		this._token  = token;
		window.WavelogWorker?.watchTokenExpiry?.(this._token);
		this._ws     = null;
		this._ready  = false;

		// A connection attempt that neither opens nor closes within this window
		// (e.g. a silently dropped connection) is treated as a failure.
		this._connectTimeoutMs = 3000;
		this._connectTimer     = null;
		this._settled          = false;  // current attempt already resolved/failed?

		// Automatic reconnect after a failed or dropped connection.
		this._maxReconnects    = 3;
		this._reconnectDelayMs = 1000;
		this._reconnectAttempt = 0;
		this._reconnectTimer   = null;
		this._everConnected    = false;  // auth_ok seen during this connect() cycle?
		this._warnedUnreachable = false; // initial "not reachable" warning shown?
		this._wantConnected    = false;  // true between connect() and disconnect()

		/** Called with the raw payload object on every incoming push frame. */
		this.onPush  = null;

		// Optional notification hooks — wired by the host to surface toasts.
		/** () => {}            initial connection not reachable within timeout */
		this.onConnectTimeout  = null;
		/** (attempt, max) => {} a reconnect attempt is starting */
		this.onReconnecting    = null;
		/** () => {}            connection re-established after a drop */
		this.onReconnected     = null;
		/** () => {}            all reconnect attempts exhausted */
		this.onReconnectFailed = null;
	}

	/**
	 * Open the WebSocket connection and authenticate.
	 * Safe to call multiple times — ignores if already connected.
	 */
	connect() {
		if (this._ws && this._ws.readyState <= WebSocket.OPEN) {
			return;
		}

		this._wantConnected     = true;
		this._everConnected     = false;
		this._warnedUnreachable = false;
		this._reconnectAttempt  = 0;
		this._openSocket();
	}

	/** Open a fresh WebSocket and attach all event handlers. */
	_openSocket() {
		const wsUrl = this._url + '/ws?topic=' + encodeURIComponent(this._topic);
		const sock  = new WebSocket(wsUrl);
		this._ws       = sock;
		this._ready    = false;
		this._settled  = false;

		// Guard so events from a superseded socket can never affect current state.
		const isCurrent = () => sock === this._ws;

		// A hung connection (no open/close) is forced to fail after the timeout.
		this._clearConnectTimer();
		this._connectTimer = setTimeout(() => {
			if (isCurrent() && !this._ready) {
				console.warn('[WsTransport] not reachable within', this._connectTimeoutMs, 'ms');
				this._handleFailure();
			}
		}, this._connectTimeoutMs);

		sock.addEventListener('open', () => {
			if (!isCurrent()) return;
			sock.send(JSON.stringify({ type: 'auth', token: this._token }));
		});

		sock.addEventListener('message', (event) => {
			if (!isCurrent()) return;
			let msg;
			try {
				msg = JSON.parse(event.data);
			} catch {
				console.warn('[WsTransport] Unparseable frame:', event.data);
				return;
			}

			switch (msg.type) {
				case 'auth_ok':
					this._ready = true;
					// Resolve the connecting phase, but stay open to a later drop
					// (the close handler must still be able to trigger reconnect).
					this._settled = false;
					this._clearConnectTimer();
					console.info('[WsTransport] authenticated on topic:', this._topic);
					// Surface success only when recovering from a previous failure.
					if (this._reconnectAttempt > 0) {
						this._invokeHook(this.onReconnected);
					}
					this._reconnectAttempt  = 0;
					this._warnedUnreachable = false;
					this._everConnected     = true;
					break;
				case 'push':
					if (typeof this.onPush === 'function') {
						this.onPush(msg.payload);
					}
					break;
				case 'error':
					console.warn('[WsTransport] server error:', msg.code);
					break;
				default:
					console.debug('[WsTransport] unknown frame type:', msg.type);
			}
		});

		sock.addEventListener('close', (event) => {
			if (!isCurrent()) return;
			this._ready = false;
			console.info('[WsTransport] closed:', event.code, event.reason);
			this._handleFailure();
		});

		sock.addEventListener('error', () => {
			if (!isCurrent()) return;
			console.warn('[WsTransport] connection error');
		});
	}

	/**
	 * Handle a failed/closed connection attempt. Deduplicated per attempt via
	 * _settled so the timeout and the close event cannot both count.
	 *
	 * The very first failure of a never-established connection raises the
	 * "not reachable" warning; afterwards we run up to _maxReconnects retries
	 * and finally raise onReconnectFailed.
	 */
	_handleFailure() {
		if (this._settled) return;
		this._settled = true;
		this._clearConnectTimer();
		this._ready = false;

		// Drop the (possibly still-connecting) socket; its events are now ignored.
		if (this._ws) {
			try { this._ws.close(); } catch { /* already closing */ }
			this._ws = null;
		}

		if (!this._wantConnected) return;

		// Initial unreachable: warn once, then start the reconnect attempts.
		if (!this._everConnected && !this._warnedUnreachable) {
			this._warnedUnreachable = true;
			this._invokeHook(this.onConnectTimeout);
		}

		this._scheduleReconnect();
	}

	/**
	 * Queue the next reconnect attempt, or give up after _maxReconnects.
	 */
	_scheduleReconnect() {
		if (this._reconnectAttempt >= this._maxReconnects) {
			console.warn('[WsTransport] giving up after', this._maxReconnects, 'reconnect attempts');
			this._wantConnected = false;
			this._invokeHook(this.onReconnectFailed);
			return;
		}

		this._reconnectAttempt++;
		const attempt = this._reconnectAttempt;
		console.info('[WsTransport] reconnect attempt', attempt, '/', this._maxReconnects);
		this._invokeHook(this.onReconnecting, attempt, this._maxReconnects);

		clearTimeout(this._reconnectTimer);
		this._reconnectTimer = setTimeout(() => {
			if (this._wantConnected) {
				this._openSocket();
			}
		}, this._reconnectDelayMs);
	}

	/**
	 * Invoke a host notification hook, never letting it disrupt the transport
	 * state machine. UI code (toasts) must not be load-bearing on reconnection.
	 */
	_invokeHook(fn, ...args) {
		if (typeof fn !== 'function') return;
		try {
			fn(...args);
		} catch (e) {
			console.warn('[WsTransport] hook threw and was ignored:', e);
		}
	}

	/** Cancel the pending connection timeout, if any. */
	_clearConnectTimer() {
		if (this._connectTimer) {
			clearTimeout(this._connectTimer);
			this._connectTimer = null;
		}
	}

	/** Close the WebSocket connection and stop any reconnect attempts. */
	disconnect() {
		this._wantConnected = false;
		this._settled       = true;
		this._clearConnectTimer();
		clearTimeout(this._reconnectTimer);
		this._reconnectTimer = null;
		if (this._ws) {
			this._ws.close();
			this._ws    = null;
			this._ready = false;
		}
	}

	/**
	 * Delegates to AjaxTransport — the Worker is receive-only.
	 * @param {Object} payload
	 * @returns {Promise}
	 */
	send(payload) {
		return this._ajax.send(payload);
	}

	/**
	 * True once the Worker has confirmed auth_ok.
	 * @returns {boolean}
	 */
	isConnected() {
		return this._ready;
	}
}
