/**
 * AccessLens — Admin JavaScript
 *
 * Handles:
 *   - EAA scope questionnaire verdict
 *   - Scan form submission via AJAX
 *   - Polling loop with backoff, retry and a max-duration cap
 *   - Results table + quota rendering
 *   - Report download via fetch() (progress feedback + JSON error mapping)
 *
 * Depends on: jQuery, accessLensData (wp_localize_script)
 *
 * @package AccessLens
 */

/* global accessLensData, jQuery */
( function ( $, config ) {
	'use strict';

	// ----------------------------------------------------------------
	// State
	// ----------------------------------------------------------------
	var pollTimeoutId   = null;
	var POLL_MS         = 3000;
	var MAX_POLL_MS     = 5 * 60 * 1000; // Tope de ~5 minutos consultando estado.
	var MAX_POLL_FAILS  = 3; // Fallos de red seguidos antes de abandonar el polling.
	var currentJobId    = null;
	var pollStartedAt   = null;
	var pollFailStreak  = 0;
	var upsellShown     = false; // Se resetea en cada scan nuevo; solo una vez por scan.
	var scanHistory     = Array.isArray( config.scanHistory ) ? config.scanHistory : []; // Feature: Progress over time.

	var HISTORY_CHART_MAX_POINTS = 20; // Ultimos N scans mostrados en la grafica (la tabla ya limita a 10).
	var HISTORY_TABLE_MAX_ROWS   = 10;
	var HISTORY_SEVERITIES       = [ 'critical', 'serious', 'moderate', 'minor' ];
	var HISTORY_COLORS           = {
		critical: '#8a1f1f',
		serious : '#d63638',
		moderate: '#b56d00',
		minor   : '#6c7378'
	};
	var HISTORY_LABELS = {
		critical: config.i18n.historyColCritical,
		serious : config.i18n.historyColSerious,
		moderate: config.i18n.historyColModerate,
		minor   : config.i18n.historyColMinor
	};

	// Initial paint of the "Progress over time" card from the history localized
	// on page load (function declarations below are hoisted within this scope).
	renderHistoryCard( scanHistory );

	// ----------------------------------------------------------------
	// EAA Scope Checker
	// ----------------------------------------------------------------

	/**
	 * Compute scope verdict from questionnaire inputs.
	 *
	 * Rules (orientative, not legal advice):
	 *   - Microempresa (<10 emp AND <2M EUR) con servicio digital => posible exenta
	 *   - Entidades publicas => siempre en scope
	 *   - Cualquier otra empresa digital => obligada o probablemente obligada
	 */
	function computeScopeVerdict( employees, revenue, serviceType ) {
		if ( ! employees || ! revenue || ! serviceType ) {
			return null;
		}
		if ( serviceType === 'not_digital' ) {
			return {
				type   : 'info',
				message: config.i18n.scopeVerdictNotDigital
			};
		}
		if ( employees === 'micro' && revenue === 'micro' ) {
			return {
				type   : 'warning',
				message: config.i18n.scopeVerdictMicroExempt
			};
		}
		return {
			type   : 'error',
			message: config.i18n.scopeVerdictInScope
		};
	}

	$( '#accesslens-check-scope' ).on( 'click', function () {
		var employees   = $( '#accesslens_employees' ).val();
		var revenue     = $( '#accesslens_revenue' ).val();
		var serviceType = $( '#accesslens_service_type' ).val();
		var verdict     = computeScopeVerdict( employees, revenue, serviceType );
		var $box        = $( '#accesslens-scope-verdict' );

		if ( ! verdict ) {
			$box.html( '<p>' + escapeHtml( config.i18n.scopeIncomplete ) + '</p>' )
				.attr( 'class', 'accesslens-scope-verdict notice notice-info' )
				.removeClass( 'hidden' );
			return;
		}

		var cssClass = {
			info   : 'notice-info',
			warning: 'notice-warning',
			error  : 'notice-error'
		}[ verdict.type ] || 'notice-info';

		$box.html( '<p><strong>' + escapeHtml( config.i18n.scopeVerdictLabel ) + '</strong> ' + escapeHtml( verdict.message ) + '</p>' )
			.attr( 'class', 'accesslens-scope-verdict notice ' + cssClass )
			.removeClass( 'hidden' );
	} );

	// ----------------------------------------------------------------
	// Scan form
	// ----------------------------------------------------------------

	$( '#accesslens-start-scan' ).on( 'click', function () {
		var url  = $( '#accesslens_scan_url' ).val().trim();
		var mode = $( '#accesslens_scan_mode' ).val();

		if ( ! url ) {
			alert( config.i18n.startError );
			return;
		}

		stopPolling();
		resetResults();
		showStatus( config.i18n.queued, 0, 0 );

		$.ajax( {
			url    : config.ajaxUrl,
			method : 'POST',
			data   : {
				action   : 'accesslens_start_scan',
				nonce    : config.nonce,
				scan_url : url,
				scan_mode: mode
			},
			success: function ( response ) {
				if ( ! response.success ) {
					showEngineError( response.data, config.i18n.startError );
					return;
				}
				currentJobId = response.data.jobId;
				renderQuota( response.data.quota );
				startPolling();
			},
			error: function () {
				showEngineError( null, config.i18n.startError );
			}
		} );
	} );

	// ----------------------------------------------------------------
	// Rescan (desde el bloque "Ultimo scan")
	// ----------------------------------------------------------------

	$( '#accesslens-rescan-button' ).on( 'click', function () {
		var url = $( this ).data( 'scan-url' );
		if ( url ) {
			$( '#accesslens_scan_url' ).val( url );
		}
		$( '.accesslens-scan-form' ).get( 0 ).scrollIntoView( { behavior: 'smooth' } );
		$( '#accesslens-start-scan' ).trigger( 'click' );
	} );

	// ----------------------------------------------------------------
	// Onboarding / review notices (dismiss via AJAX)
	// ----------------------------------------------------------------

	$( '.accesslens-dismiss-onboarding' ).on( 'click', function () {
		var $notice = $( '#accesslens-onboarding-notice' );
		$.ajax( {
			url   : config.ajaxUrl,
			method: 'POST',
			data  : {
				action: 'accesslens_dismiss_onboarding',
				nonce : config.nonce
			}
		} );
		$notice.slideUp( 200, function () {
			$notice.remove();
		} );
	} );

	$( '.accesslens-dismiss-review' ).on( 'click', function () {
		dismissReviewNotice();
	} );

	$( '.accesslens-leave-review' ).on( 'click', function () {
		// El enlace abre en pestana nueva (target=_blank); tambien la descartamos
		// para no volver a preguntar tras dejar la resena.
		dismissReviewNotice();
	} );

	function dismissReviewNotice() {
		var $notice = $( '#accesslens-review-notice' );
		$.ajax( {
			url   : config.ajaxUrl,
			method: 'POST',
			data  : {
				action: 'accesslens_dismiss_review',
				nonce : config.nonce
			}
		} );
		$notice.slideUp( 200, function () {
			$notice.remove();
		} );
	}

	// ----------------------------------------------------------------
	// Print results
	// ----------------------------------------------------------------

	$( '#accesslens-print-results' ).on( 'click', function () {
		window.print();
	} );

	// ----------------------------------------------------------------
	// Polling
	// ----------------------------------------------------------------

	function startPolling() {
		pollStartedAt  = Date.now();
		pollFailStreak = 0;
		doPoll(); // immediate first poll, then self-schedules.
	}

	function stopPolling() {
		if ( pollTimeoutId ) {
			clearTimeout( pollTimeoutId );
			pollTimeoutId = null;
		}
	}

	function scheduleNextPoll() {
		// Backoff suave tras fallos de red: espera mas cada fallo seguido.
		var delay = POLL_MS * ( 1 + pollFailStreak );
		pollTimeoutId = setTimeout( doPoll, delay );
	}

	function doPoll() {
		if ( pollStartedAt && ( Date.now() - pollStartedAt ) > MAX_POLL_MS ) {
			stopPolling();
			showError( config.i18n.timeoutError );
			return;
		}

		$.ajax( {
			url    : config.ajaxUrl,
			method : 'POST',
			data   : {
				action: 'accesslens_poll_scan',
				nonce : config.nonce
			},
			success: function ( response ) {
				pollFailStreak = 0;

				if ( ! response.success ) {
					stopPolling();
					showEngineError( response.data, config.i18n.pollError );
					return;
				}
				handleStatusUpdate( response.data );
			},
			error: function () {
				pollFailStreak++;

				if ( pollFailStreak > MAX_POLL_FAILS ) {
					stopPolling();
					showEngineError( null, config.i18n.pollError );
					return;
				}

				// Fallo de red puntual: no abandonamos, avisamos y reintentamos con backoff.
				showStatus( config.i18n.reconnecting, 0, 0 );
				scheduleNextPoll();
			}
		} );

		if ( pollFailStreak === 0 ) {
			// Solo se re-programa aqui en el camino feliz; el camino de error ya
			// programa su propio reintento (o detiene el polling) arriba.
			pollTimeoutId = setTimeout( doPoll, POLL_MS );
		}
	}

	function handleStatusUpdate( data ) {
		var status   = data.status;
		var progress = data.progress || { total: 0, done: 0 };
		var pct      = progress.total > 0 ? Math.round( ( progress.done / progress.total ) * 100 ) : 0;

		var labelMap = {
			queued     : config.i18n.queued,
			discovering: config.i18n.discovering,
			running    : config.i18n.running,
			done       : config.i18n.done,
			error      : config.i18n.error
		};
		var label = labelMap[ status ] || status;

		showStatus( label, progress.done, progress.total );
		$( '#accesslens-progress-bar' ).val( pct );
		$( '#accesslens-progress-label' ).text( progress.done + ' / ' + progress.total );
		renderQuota( data.quota );

		if ( status === 'done' || status === 'error' ) {
			stopPolling();
		}

		if ( status === 'done' && Array.isArray( data.results ) && data.results.length > 0 ) {
			renderResults( data.results );
		}

		if ( status === 'done' && Array.isArray( data.history ) ) {
			scanHistory = data.history;
			renderHistoryCard( scanHistory );
		}

		if ( status === 'error' && data.error ) {
			showError( data.error );
		}
	}

	// ----------------------------------------------------------------
	// Quota
	// ----------------------------------------------------------------

	/**
	 * Render the quota object the engine returns ({plan, scansUsed, scansLimit, resetAt}).
	 *
	 * @param {Object|null} quota
	 */
	function renderQuota( quota ) {
		var $box = $( '#accesslens-quota-info' );

		if ( ! quota || typeof quota !== 'object' ) {
			$box.addClass( 'hidden' ).empty();
			return;
		}

		var parts = [];
		if ( quota.plan ) {
			parts.push( '<strong>' + escapeHtml( config.i18n.quotaPlanLabel ) + ':</strong> ' + escapeHtml( quota.plan ) );
		}
		if ( typeof quota.scansUsed !== 'undefined' && typeof quota.scansLimit !== 'undefined' ) {
			parts.push( '<strong>' + escapeHtml( config.i18n.quotaUsedLabel ) + ':</strong> ' + escapeHtml( String( quota.scansUsed ) ) + ' / ' + escapeHtml( String( quota.scansLimit ) ) );
		}
		if ( quota.resetAt ) {
			parts.push( '<strong>' + escapeHtml( config.i18n.quotaResetLabel ) + ':</strong> ' + escapeHtml( quota.resetAt ) );
		}

		if ( parts.length === 0 ) {
			$box.addClass( 'hidden' ).empty();
			return;
		}

		$box.html( '<p>' + parts.join( ' &middot; ' ) + '</p>' ).removeClass( 'hidden' );
	}

	// ----------------------------------------------------------------
	// Results rendering
	// ----------------------------------------------------------------

	function renderResults( results ) {
		var $body    = $( '#accesslens-results-body' ).empty();
		var $wrap    = $( '#accesslens-results-wrap' ).removeClass( 'hidden' );
		var totalAll = 0;
		var severityTotals = { critical: 0, serious: 0, moderate: 0, minor: 0 };

		results.forEach( function ( page ) {
			var sum      = page.summary || {};
			var topRules = buildTopRules( page.violations );

			$body.append(
				'<tr>' +
				'<td><a href="' + escapeAttr( page.url ) + '" target="_blank" rel="noopener">' + escapeHtml( page.url ) + '</a></td>' +
				'<td>' + escapeHtml( page.pageTitle ) + '</td>' +
				'<td class="accesslens-col-critical accesslens-severity-critical">' + escapeHtml( String( sum.critical || 0 ) ) + '</td>' +
				'<td class="accesslens-col-serious accesslens-severity-serious">' + escapeHtml( String( sum.serious || 0 ) ) + '</td>' +
				'<td class="accesslens-col-moderate accesslens-severity-moderate">' + escapeHtml( String( sum.moderate || 0 ) ) + '</td>' +
				'<td class="accesslens-col-minor accesslens-severity-minor">' + escapeHtml( String( sum.minor || 0 ) ) + '</td>' +
				'<td>' + escapeHtml( String( sum.total || 0 ) ) + '</td>' +
				'<td><small>' + escapeHtml( topRules ) + '</small></td>' +
				'</tr>'
			);
			totalAll += ( sum.total || 0 );
			severityTotals.critical += ( sum.critical || 0 );
			severityTotals.serious  += ( sum.serious || 0 );
			severityTotals.moderate += ( sum.moderate || 0 );
			severityTotals.minor    += ( sum.minor || 0 );
		} );

		$( '#accesslens-results-summary' ).html(
			'<p>' +
			escapeHtml( config.i18n.resultsSummaryPages.replace( '%d', String( results.length ) ) ) + ' ' +
			escapeHtml( config.i18n.resultsSummaryViolations.replace( '%d', String( totalAll ) ) ) +
			'</p>'
		);

		renderSeverityPanel( severityTotals );
		maybeShowUpsell( totalAll );

		$wrap.get( 0 ).scrollIntoView( { behavior: 'smooth' } );
	}

	/**
	 * Update the 4 severity counters above the table. Never a global score —
	 * solo conteos por severidad (accesibles: etiqueta de texto + color).
	 *
	 * @param {Object} totals {critical, serious, moderate, minor}
	 */
	function renderSeverityPanel( totals ) {
		$( '#accesslens-severity-count-critical' ).text( String( totals.critical || 0 ) );
		$( '#accesslens-severity-count-serious' ).text( String( totals.serious || 0 ) );
		$( '#accesslens-severity-count-moderate' ).text( String( totals.moderate || 0 ) );
		$( '#accesslens-severity-count-minor' ).text( String( totals.minor || 0 ) );
		$( '#accesslens-severity-panel' ).removeClass( 'hidden' );
		$( '#accesslens-severity-honesty' ).removeClass( 'hidden' );
	}

	/**
	 * Contextual post-scan upsell (Feature 5): solo usuarios Free, solo si el
	 * scan tuvo violaciones, y una unica vez por scan (no en cada poll).
	 *
	 * @param {number} totalViolations
	 */
	function maybeShowUpsell( totalViolations ) {
		var $box = $( '#accesslens-upsell-box' );

		if ( config.isPro || totalViolations <= 0 || upsellShown ) {
			return;
		}

		upsellShown = true;

		$box.html(
			'<p><strong>' + escapeHtml( config.i18n.upsellTitle ) + '</strong> ' + escapeHtml( config.i18n.upsellBody ) + ' ' + escapeHtml( config.i18n.upsellPrice ) + '</p>' +
			'<p><a href="' + escapeAttr( config.upgradeUrl ) + '" target="_blank" rel="noopener" class="button button-primary">' + escapeHtml( config.i18n.upsellCta ) + '</a></p>'
		).removeClass( 'hidden' );
	}

	/**
	 * Build a short string of top rule IDs from violations array.
	 *
	 * @param {Array} violations
	 * @returns {string}
	 */
	function buildTopRules( violations ) {
		if ( ! Array.isArray( violations ) || violations.length === 0 ) {
			return '—';
		}
		var sorted = violations.slice().sort( function ( a, b ) {
			return ( b.nodes || 0 ) - ( a.nodes || 0 );
		} );
		return sorted.slice( 0, 3 ).map( function ( v ) {
			return v.id + ' (' + v.impact + ')';
		} ).join( ', ' );
	}

	// ----------------------------------------------------------------
	// Scan history ("Progress over time")
	// ----------------------------------------------------------------

	/**
	 * Render (or hide) the "Progress over time" card: honest trend line, SVG
	 * stacked-bar chart and the collapsible recent-scans table. Called once on
	 * page load with the history localized via wp_localize_script, and again
	 * after each completed scan (see handleStatusUpdate).
	 *
	 * @param {Array} history Chronological (oldest first) list of
	 *   {timestamp, url, mode, pagesScanned, stats:{critical,serious,moderate,minor}}.
	 */
	function renderHistoryCard( history ) {
		var $wrap = $( '#accesslens-history-wrap' );

		if ( ! Array.isArray( history ) || history.length < 2 ) {
			$wrap.addClass( 'hidden' );
			return;
		}

		$wrap.removeClass( 'hidden' );
		renderHistoryTrend( history );
		$( '#accesslens-history-chart-wrap' ).html( buildHistorySvg( history ) );
		renderHistoryTable( history );
	}

	/**
	 * Honest one-line trend summary: only a critical-issues delta between the
	 * first and latest scan on record. Never a percentage or a compliance-
	 * sounding score (legal guardrail — see class-accesslens-freemius.php).
	 *
	 * @param {Array} history
	 */
	function renderHistoryTrend( history ) {
		var first = history[ 0 ];
		var last  = history[ history.length - 1 ];
		var text  = config.i18n.historyTrend
			.replace( '%1$s', formatShortDate( first.timestamp ) )
			.replace( '%2$s', String( ( first.stats && first.stats.critical ) || 0 ) )
			.replace( '%3$s', String( ( last.stats && last.stats.critical ) || 0 ) );

		$( '#accesslens-history-trend' ).text( text );
	}

	/**
	 * Build an inline SVG stacked-bar chart (no external libraries): one bar
	 * per scan, one segment per severity, so both the per-severity trend and
	 * the total violation volume are readable at a glance. The color legend
	 * lives outside the SVG (see dashboard.php) so severity is never
	 * color-only; each segment also carries a native <title> tooltip.
	 *
	 * @param {Array} history
	 * @returns {string} SVG markup.
	 */
	function buildHistorySvg( history ) {
		var points = history.slice( -HISTORY_CHART_MAX_POINTS );

		var width   = 700;
		var height  = 260;
		var marginL = 34;
		var marginB = 28;
		var marginT = 12;
		var marginR = 12;
		var plotW   = width - marginL - marginR;
		var plotH   = height - marginT - marginB;

		var maxTotal = points.reduce( function ( max, entry ) {
			var total = HISTORY_SEVERITIES.reduce( function ( sum, sev ) {
				return sum + ( ( entry.stats && entry.stats[ sev ] ) || 0 );
			}, 0 );
			return Math.max( max, total );
		}, 0 ) || 1;

		var gap      = points.length > 1 ? 10 : 0;
		var barWidth = Math.max( ( plotW - gap * ( points.length - 1 ) ) / points.length, 4 );

		var bars  = '';
		var ticks = '';

		points.forEach( function ( entry, index ) {
			var x = marginL + index * ( barWidth + gap );
			var y = marginT + plotH;
			var stats = entry.stats || {};

			HISTORY_SEVERITIES.forEach( function ( sev ) {
				var value = stats[ sev ] || 0;
				if ( value <= 0 ) {
					return;
				}
				var segH = ( value / maxTotal ) * plotH;
				y -= segH;
				bars += '<rect x="' + round2( x ) + '" y="' + round2( y ) + '" width="' + round2( barWidth ) + '" height="' + round2( segH ) +
					'" fill="' + HISTORY_COLORS[ sev ] + '">' +
					'<title>' + escapeHtml( HISTORY_LABELS[ sev ] + ': ' + value ) + '</title>' +
					'</rect>';
			} );

			// Etiqueta de fecha corta bajo cada barra.
			var labelX = x + barWidth / 2;
			var labelY = marginT + plotH + 16;
			ticks += '<text x="' + round2( labelX ) + '" y="' + round2( labelY ) + '" text-anchor="middle" font-size="10" fill="#50575e">' +
				escapeHtml( formatShortDate( entry.timestamp ) ) + '</text>';
		} );

		// Eje Y: solo 0 y el maximo, como referencia de escala (sin gridlines densas).
		var axis = '<line x1="' + marginL + '" y1="' + marginT + '" x2="' + marginL + '" y2="' + ( marginT + plotH ) + '" stroke="#c3c4c7" />' +
			'<line x1="' + marginL + '" y1="' + ( marginT + plotH ) + '" x2="' + ( width - marginR ) + '" y2="' + ( marginT + plotH ) + '" stroke="#c3c4c7" />' +
			'<text x="2" y="' + ( marginT + 6 ) + '" font-size="10" fill="#50575e">' + escapeHtml( String( maxTotal ) ) + '</text>' +
			'<text x="2" y="' + ( marginT + plotH ) + '" font-size="10" fill="#50575e">0</text>';

		var first      = history[ 0 ];
		var last       = history[ history.length - 1 ];
		var firstTotal = HISTORY_SEVERITIES.reduce( function ( sum, sev ) { return sum + ( ( first.stats && first.stats[ sev ] ) || 0 ); }, 0 );
		var lastTotal  = HISTORY_SEVERITIES.reduce( function ( sum, sev ) { return sum + ( ( last.stats && last.stats[ sev ] ) || 0 ); }, 0 );

		var ariaLabel = config.i18n.historyChartAriaLabel
			.replace( '%1$s', String( firstTotal ) )
			.replace( '%2$s', formatShortDate( first.timestamp ) )
			.replace( '%3$s', String( lastTotal ) )
			.replace( '%4$s', formatShortDate( last.timestamp ) );

		return '<svg viewBox="0 0 ' + width + ' ' + height + '" class="accesslens-history-svg" role="img" aria-label="' + escapeAttr( ariaLabel ) + '">' +
			axis + bars + ticks +
			'</svg>';
	}

	/**
	 * Render the collapsible <details> table: the 10 most recent scans,
	 * newest first. Counts only — no violation detail (that lives in the
	 * results table / accesslens_last_scan).
	 *
	 * @param {Array} history
	 */
	function renderHistoryTable( history ) {
		var $body = $( '#accesslens-history-table-body' ).empty();
		var rows  = history.slice( -HISTORY_TABLE_MAX_ROWS ).slice().reverse();

		rows.forEach( function ( entry ) {
			var stats = entry.stats || {};
			$body.append(
				'<tr>' +
				'<td>' + escapeHtml( formatShortDate( entry.timestamp ) ) + '</td>' +
				'<td><a href="' + escapeAttr( entry.url ) + '" target="_blank" rel="noopener">' + escapeHtml( entry.url ) + '</a></td>' +
				'<td>' + escapeHtml( historyModeLabel( entry.mode ) ) + '</td>' +
				'<td>' + escapeHtml( String( entry.pagesScanned || 0 ) ) + '</td>' +
				'<td class="accesslens-col-critical accesslens-severity-critical">' + escapeHtml( String( stats.critical || 0 ) ) + '</td>' +
				'<td class="accesslens-col-serious accesslens-severity-serious">' + escapeHtml( String( stats.serious || 0 ) ) + '</td>' +
				'<td class="accesslens-col-moderate accesslens-severity-moderate">' + escapeHtml( String( stats.moderate || 0 ) ) + '</td>' +
				'<td class="accesslens-col-minor accesslens-severity-minor">' + escapeHtml( String( stats.minor || 0 ) ) + '</td>' +
				'</tr>'
			);
		} );
	}

	/**
	 * Translate a stored scan mode ('single'|'sitemap'|'crawl') to its label.
	 *
	 * @param {string} mode
	 * @returns {string}
	 */
	function historyModeLabel( mode ) {
		var map = {
			single : config.i18n.historyModeSingle,
			sitemap: config.i18n.historyModeSitemap,
			crawl  : config.i18n.historyModeCrawl
		};
		return map[ mode ] || mode;
	}

	/**
	 * Format a unix timestamp (seconds) as a short, locale-aware date for
	 * chart ticks and the history table (e.g. "24 Jul").
	 *
	 * @param {number} timestamp Seconds since epoch.
	 * @returns {string}
	 */
	function formatShortDate( timestamp ) {
		var date = new Date( ( Number( timestamp ) || 0 ) * 1000 );
		return date.toLocaleDateString( undefined, { day: 'numeric', month: 'short' } );
	}

	/**
	 * Round to 2 decimals — keeps generated SVG coordinates compact.
	 *
	 * @param {number} n
	 * @returns {number}
	 */
	function round2( n ) {
		return Math.round( n * 100 ) / 100;
	}

	// ----------------------------------------------------------------
	// Report download
	// ----------------------------------------------------------------

	$( '#accesslens-download-report' ).on( 'click', function () {
		var $btn         = $( this );
		var originalText = $btn.text();

		$btn.prop( 'disabled', true );
		showReportStatus( config.i18n.generatingReport );

		var body = new URLSearchParams();
		body.append( 'action', 'accesslens_get_report' );
		body.append( 'nonce', config.nonce );
		body.append( 'site_name', $( '#accesslens_report_site_name' ).val() );
		body.append( 'entity_type', $( '#accesslens_report_entity_type' ).val() );
		body.append( 'contact_email', $( '#accesslens_report_contact_email' ).val() );
		body.append( 'contact_url', $( '#accesslens_report_contact_url' ).val() );
		body.append( 'prepared_by', $( '#accesslens_report_prepared_by' ).val() );

		fetch( config.ajaxUrl, {
			method     : 'POST',
			credentials: 'same-origin',
			body       : body
		} )
			.then( function ( response ) {
				var contentType = response.headers.get( 'Content-Type' ) || '';

				if ( contentType.indexOf( 'application/json' ) !== -1 ) {
					return response.json().then( function ( json ) {
						return Promise.reject( { engineJson: json } );
					} );
				}

				if ( ! response.ok ) {
					return Promise.reject( { engineJson: null } );
				}

				var disposition = response.headers.get( 'Content-Disposition' ) || '';
				var match       = /filename="?([^"]+)"?/.exec( disposition );
				var filename    = match ? match[ 1 ] : 'accesslens-report.pdf';

				return response.blob().then( function ( blob ) {
					return { blob: blob, filename: filename };
				} );
			} )
			.then( function ( result ) {
				var url  = window.URL.createObjectURL( result.blob );
				var link = document.createElement( 'a' );
				link.href     = url;
				link.download = result.filename;
				document.body.appendChild( link );
				link.click();
				document.body.removeChild( link );
				window.URL.revokeObjectURL( url );
				showReportStatus( '' );
			} )
			.catch( function ( failure ) {
				var data = failure && failure.engineJson ? failure.engineJson.data : null;
				showEngineError( data, config.i18n.reportError, true );
			} )
			.then( function () {
				$btn.prop( 'disabled', false ).text( originalText );
			} );
	} );

	// ----------------------------------------------------------------
	// UI helpers
	// ----------------------------------------------------------------

	function showStatus( label, done, total ) {
		$( '#accesslens-scan-status' ).removeClass( 'hidden' );
		$( '#accesslens-status-text' ).text( label );
		$( '#accesslens-progress-bar' ).val( total > 0 ? Math.round( ( done / total ) * 100 ) : 0 );
		$( '#accesslens-progress-label' ).text( total > 0 ? done + ' / ' + total : '' );
	}

	function showError( msg ) {
		$( '#accesslens-scan-status' ).removeClass( 'hidden' );
		$( '#accesslens-status-text' ).text( config.i18n.error + ': ' + msg );
	}

	/**
	 * Show an error coming from an AJAX response mapped by AccessLens_Ajax::engine_error_to_response(),
	 * with optional upgradeUrl (pro_required) / resetAt (quota_exceeded) / retryAfter (rate_limited, service_busy).
	 * Renders into the scan status area unless `intoReport` is true.
	 *
	 * @param {Object|null} data
	 * @param {string}      fallbackMsg
	 * @param {boolean}     [intoReport]
	 */
	function showEngineError( data, fallbackMsg, intoReport ) {
		var message = ( data && data.message ) ? data.message : fallbackMsg;
		var html    = escapeHtml( message );

		if ( data && data.upgradeUrl ) {
			html += ' <a href="' + escapeAttr( data.upgradeUrl ) + '" target="_blank" rel="noopener">' + escapeHtml( config.i18n.upgradeLinkText ) + '</a>';
		}
		if ( data && data.retryAfter ) {
			html += ' (' + escapeHtml( String( data.retryAfter ) ) + 's)';
		}

		if ( intoReport ) {
			$( '#accesslens-report-status' ).html( html );
			return;
		}

		$( '#accesslens-scan-status' ).removeClass( 'hidden' );
		$( '#accesslens-status-text' ).html( config.i18n.error + ': ' + html );
	}

	function showReportStatus( msg ) {
		$( '#accesslens-report-status' ).text( msg );
	}

	function resetResults() {
		$( '#accesslens-results-wrap' ).addClass( 'hidden' );
		$( '#accesslens-results-body' ).empty();
		$( '#accesslens-results-summary' ).empty();
		$( '#accesslens-quota-info' ).addClass( 'hidden' ).empty();
		$( '#accesslens-upsell-box' ).addClass( 'hidden' ).empty();
		upsellShown  = false;
		currentJobId = null;
	}

	/**
	 * Escape string for HTML text content.
	 *
	 * @param {string} str
	 * @returns {string}
	 */
	function escapeHtml( str ) {
		var div = document.createElement( 'div' );
		div.appendChild( document.createTextNode( str ) );
		return div.innerHTML;
	}

	/**
	 * Escape string for use in an HTML attribute.
	 *
	 * @param {string} str
	 * @returns {string}
	 */
	function escapeAttr( str ) {
		return String( str )
			.replace( /&/g, '&amp;' )
			.replace( /"/g, '&quot;' )
			.replace( /'/g, '&#39;' )
			.replace( /</g, '&lt;' )
			.replace( />/g, '&gt;' );
	}

}( jQuery, accessLensData ) );
