/**
 * Kantan Analytics — video engagement tracking.
 *
 * Detects embedded videos (YouTube, Vimeo, self-hosted <video>) and emits a
 * normalized event stream (impression / play / progress / complete) tied to
 * the visitor's Kantan session, so video engagement can be correlated with
 * journeys and conversions.
 *
 * UMD wrapper: in the browser it self-installs as window.KantanVideo and boots;
 * under Node (module.exports) it exposes the pure helpers for unit testing
 * without touching any browser API.
 *
 * The YouTube problem this solves: a plain
 *   <iframe src="https://www.youtube.com/embed/VIDEOID">
 * reports nothing to JavaScript. The IFrame Player API only works when the
 * embed carries enablejsapi=1. We rewrite such iframes (adding enablejsapi=1,
 * an origin, and optionally the privacy-friendly youtube-nocookie.com host)
 * and bind the API — including embeds added lazily / by page builders / inside
 * popups, via a MutationObserver.
 *
 * Engagement is accumulated CLIENT-SIDE into a fixed 100-bucket array
 * (bucket = 1% of duration): reached[i] powers the retention/drop-off curve,
 * plays[i] the rewatch heatmap. Buckets are sent as compact deltas only on
 * pause / hidden / ended — never per second.
 */
(function (root, factory) {
	if (typeof module === 'object' && module.exports) {
		// Node / test context — pure helpers only, no auto-boot.
		module.exports = factory(root);
	} else {
		root.KantanVideo = factory(root);
		root.KantanVideo._autoboot();
	}
})(typeof self !== 'undefined' ? self : this, function (root) {
	'use strict';

	var YT_HOSTS = ['youtube.com', 'youtube-nocookie.com', 'm.youtube.com'];
	// Reserved /embed/ path segments that are not literal video ids.
	var YT_SPECIAL = { videoseries: 1, live_stream: 1 };

	/* ───────────────────────── Pure helpers (unit-tested) ───────────────────────── */

	/**
	 * Parse a YouTube embed iframe src.
	 * @param {string} src
	 * @returns {?{host:string, videoId:string, special:boolean, url:URL}}
	 *          null when src is not a YouTube /embed/ URL.
	 */
	function parseYouTube(src) {
		if (!src || typeof src !== 'string') {
			return null;
		}
		// Allow protocol-relative ("//www.youtube.com/...") by assuming https.
		var normalized = src.slice(0, 2) === '//' ? 'https:' + src : src;
		var url;
		try {
			url = new URL(normalized);
		} catch (e) {
			return null;
		}
		var host = url.hostname.replace(/^www\./, '');
		if (YT_HOSTS.indexOf(host) === -1) {
			return null;
		}
		if (url.pathname.indexOf('/embed/') !== 0) {
			return null; // watch URLs, channel URLs, etc. are not embeds.
		}
		var id = url.pathname.slice('/embed/'.length).split('/')[0];
		if (!id) {
			return null;
		}
		return { host: host, videoId: id, special: !!YT_SPECIAL[id], url: url };
	}

	function isYouTubeEmbed(src) {
		return parseYouTube(src) !== null;
	}

	/**
	 * Compute the rewritten src needed to make a YouTube embed API-controllable.
	 * Adds enablejsapi=1, sets origin (when provided), and optionally switches to
	 * youtube-nocookie.com — preserving all existing query params.
	 *
	 * @param {string} src
	 * @param {{origin?:string, nocookie?:boolean}} [opts]
	 * @returns {?string} the new src, or null when no change is needed (already
	 *          API-ready) or src is not a YouTube embed — callers use null to mean
	 *          "do not reload this iframe".
	 */
	function rewriteYouTubeSrc(src, opts) {
		opts = opts || {};
		var info = parseYouTube(src);
		if (!info) {
			return null;
		}
		var url = info.url;
		var changed = false;

		if (opts.nocookie && info.host === 'youtube.com') {
			url.hostname = url.hostname.replace('youtube.com', 'youtube-nocookie.com');
			changed = true;
		}
		if (url.searchParams.get('enablejsapi') !== '1') {
			url.searchParams.set('enablejsapi', '1');
			changed = true;
		}
		if (opts.origin) {
			if (url.searchParams.get('origin') !== opts.origin) {
				url.searchParams.set('origin', opts.origin);
				changed = true;
			}
		}
		return changed ? url.toString() : null;
	}

	/**
	 * Parse a Vimeo embed iframe src.
	 * @param {string} src
	 * @returns {?{videoId:string, url:URL}} null when src is not a Vimeo player URL.
	 */
	function parseVimeo(src) {
		if (!src || typeof src !== 'string') {
			return null;
		}
		var normalized = src.slice(0, 2) === '//' ? 'https:' + src : src;
		var url;
		try {
			url = new URL(normalized);
		} catch (e) {
			return null;
		}
		if (url.hostname.replace(/^www\./, '') !== 'player.vimeo.com') {
			return null;
		}
		var m = url.pathname.match(/^\/video\/(\d+)/);
		if (!m) {
			return null;
		}
		return { videoId: m[1], url: url };
	}

	/**
	 * Bucket a playhead position into one of 100 duration-relative slots (0–99).
	 * Duration-independent so engagement curves aggregate across any video length.
	 */
	function bucketOf(position, duration) {
		if (!(duration > 0)) {
			return 0;
		}
		var b = Math.floor((position / duration) * 100);
		return b < 0 ? 0 : (b > 99 ? 99 : b);
	}

	/**
	 * Stable key for a self-hosted <video>: an explicit data-kantan-video-id wins,
	 * else the source URL's path (readable in the dashboard, unique per site).
	 */
	function html5Key(explicitId, src) {
		if (explicitId) {
			return 'html5:' + String(explicitId).slice(0, 150);
		}
		if (!src) {
			return null;
		}
		try {
			var url = new URL(src, 'https://x.invalid/');
			return 'html5:' + url.pathname.slice(0, 150);
		} catch (e) {
			return null;
		}
	}

	/* ───────────────────────── Browser-only runtime ───────────────────────── */

	var config = {
		ajaxUrl: '',
		providers: { youtube: true, vimeo: true, html5: true },
		nocookie: true,
		excludeAutoplay: true,
		origin: '',
		// Default sink: queue for the beacon flusher. The spike harness overrides
		// this with a log panel via window.kantanVideoConfig.onEvent.
		onEvent: null
	};

	var booted = false;
	var ytApiState = 'idle';     // idle | loading | ready
	var ytPending = [];          // iframes awaiting the YouTube API
	var vimeoApiState = 'idle';  // idle | loading | ready
	var vimeoPending = [];       // iframes awaiting the Vimeo SDK
	var seq = 0;                 // per-page instance counter (disambiguates duplicates)
	var queue = [];              // events awaiting a beacon flush
	var flushTimer = null;

	// Safety circuit breaker: cap total element processings so a pathological
	// re-processing loop (e.g. a player library re-inserting iframes that the
	// observer then re-handles) can never pin the main thread / hang the page.
	var MAX_PROCESS = 40;
	var processCount = 0;
	var observerRef = null;
	var processedIds = {}; // ids we've already bound (a player lib may recreate iframes)

	// Optional lifecycle tracing for the spike harness. No-op unless a
	// config.onDebug(stage, detail) hook is supplied.
	function debug(stage, detail) {
		if (config.onDebug) {
			try { config.onDebug(stage, detail); } catch (e) {}
		}
	}

	function tripBreaker() {
		if (processCount < MAX_PROCESS) {
			processCount++;
			return false;
		}
		if (observerRef) { try { observerRef.disconnect(); } catch (e) {} observerRef = null; }
		debug('circuit-breaker', { processed: processCount });
		if (typeof console !== 'undefined') {
			console.warn('[KantanVideo] circuit breaker tripped at ' + processCount + ' — observer disconnected (element re-processing loop)');
		}
		return true;
	}

	/* ---- Session identity (same storage keys as tracking.js) ---- */

	function getSessionId() {
		try {
			var sid = sessionStorage.getItem('kantan_session_id');
			if (!sid) {
				sid = 'kantan_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
				sessionStorage.setItem('kantan_session_id', sid);
			}
			return sid;
		} catch (e) {
			return '';
		}
	}

	function getVisitorId() {
		try {
			var vid = localStorage.getItem('kantan_visitor_id');
			if (!vid) {
				vid = 'kv_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
				localStorage.setItem('kantan_visitor_id', vid);
			}
			return vid;
		} catch (e) {
			return '';
		}
	}

	/* ---- Event sink: batch + sendBeacon ---- */

	function emit(type, provider, videoKey, payload) {
		var evt = {
			type: type,
			provider: provider,
			video_key: videoKey,
			page_url: (typeof location !== 'undefined') ? location.href : ''
		};
		for (var k in payload) {
			if (Object.prototype.hasOwnProperty.call(payload, k)) {
				evt[k] = payload[k];
			}
		}
		if (config.onEvent) {
			// Harness/testing sink replaces the network entirely.
			try { config.onEvent(evt); } catch (e) { /* a broken sink must never break playback */ }
			return;
		}
		queue.push(evt);
		scheduleFlush();
	}

	function scheduleFlush() {
		if (flushTimer) {
			return;
		}
		flushTimer = root.setTimeout(function () {
			flushTimer = null;
			flushQueue();
		}, 500);
	}

	function flushQueue() {
		if (!queue.length || !config.ajaxUrl) {
			return;
		}
		var batch = queue.splice(0, 20);
		var data = new FormData();
		data.append('action', 'kantan_track_video');
		data.append('session_id', getSessionId());
		data.append('visitor_id', getVisitorId());
		data.append('events', JSON.stringify(batch));
		try {
			if (navigator.sendBeacon) {
				navigator.sendBeacon(config.ajaxUrl, data);
			} else {
				fetch(config.ajaxUrl, { method: 'POST', body: data, keepalive: true });
			}
		} catch (e) { /* tracking must never break the page */ }
		if (queue.length) {
			scheduleFlush();
		}
	}

	// One impression per video_key per session — the play-rate denominator.
	function markImpression(videoKey) {
		var key = 'kantan_vimp_' + videoKey;
		try {
			if (sessionStorage.getItem(key)) {
				return false;
			}
			sessionStorage.setItem(key, '1');
			return true;
		} catch (e) {
			return true;
		}
	}

	/* ---- Per-instance engagement state ---- */

	/**
	 * Shared engagement tracker used by all three adapters. Collects bucket and
	 * watch-second DELTAS between flushes; flush(trigger) emits one compact
	 * progress event and resets the deltas. Buckets are skipped for live/very
	 * short content (duration unknown, infinite, or < 10s).
	 */
	function createTracker(provider, videoKey, meta) {
		var state = {
			started: false,
			completed: false,
			duration: 0,
			reachedEver: {},   // bucket -> 1, whole session (dedup for retention)
			reachedDelta: [],  // buckets newly reached since last flush
			playsDelta: {},    // bucket -> n since last flush
			watchDelta: 0,     // whole seconds watched since last flush
			lastPosition: 0
		};

		function bucketable() {
			return isFinite(state.duration) && state.duration >= 10;
		}

		return {
			impression: function (title) {
				if (markImpression(videoKey)) {
					emit('impression', provider, videoKey, {
						video_title: title || '',
						duration: isFinite(state.duration) ? state.duration : 0
					});
				}
			},
			play: function () {
				if (!state.started) {
					state.started = true;
					emit('play', provider, videoKey, { autoplay: meta.autoplay ? 1 : 0 });
				}
			},
			/** One ~1s playback tick at `position` of `duration`. */
			sample: function (position, duration) {
				if (duration > 0) {
					state.duration = duration;
				}
				state.watchDelta++;
				state.lastPosition = position;
				if (!bucketable()) {
					return;
				}
				var b = bucketOf(position, state.duration);
				if (!state.reachedEver[b]) {
					state.reachedEver[b] = 1;
					state.reachedDelta.push(b);
				}
				state.playsDelta[b] = (state.playsDelta[b] || 0) + 1;
			},
			/** Send accumulated deltas. trigger: pause | hidden | ended | unload */
			flush: function (trigger) {
				if (!state.watchDelta && !state.reachedDelta.length) {
					return;
				}
				emit('progress', provider, videoKey, {
					position: Math.round(state.lastPosition),
					duration: isFinite(state.duration) ? Math.round(state.duration) : 0,
					watch: state.watchDelta,
					reached: state.reachedDelta,
					plays: state.playsDelta,
					trigger: trigger
				});
				state.reachedDelta = [];
				state.playsDelta = {};
				state.watchDelta = 0;
			},
			complete: function (position) {
				this.flush('ended');
				if (state.completed) {
					return;
				}
				state.completed = true;
				emit('complete', provider, videoKey, {
					position: Math.round(position || state.lastPosition),
					duration: isFinite(state.duration) ? Math.round(state.duration) : 0
				});
			}
		};
	}

	var trackers = []; // all live trackers, flushed together on hidden/unload

	function computeOrigin() {
		if (typeof location === 'undefined') {
			return '';
		}
		// Only meaningful over http(s); omit for file:// so the harness works.
		return (location.protocol === 'http:' || location.protocol === 'https:') ? location.origin : '';
	}

	/* ---- YouTube adapter ---- */

	function loadYouTubeApi() {
		if (ytApiState !== 'idle') {
			return;
		}
		ytApiState = 'loading';
		debug('api:load', {});
		// onYouTubeIframeAPIReady is a single global the API calls once. Chain any
		// existing handler so we coexist with other plugins/themes using the API.
		var prev = root.onYouTubeIframeAPIReady;
		root.onYouTubeIframeAPIReady = function () {
			if (typeof prev === 'function') {
				try { prev(); } catch (e) {}
			}
			ytApiState = 'ready';
			var queued = ytPending.slice();
			ytPending.length = 0;
			debug('api:ready', { queued: queued.length });
			queued.forEach(bindYouTubePlayer);
		};
		var tag = document.createElement('script');
		tag.src = 'https://www.youtube.com/iframe_api';
		var first = document.getElementsByTagName('script')[0];
		if (first && first.parentNode) {
			first.parentNode.insertBefore(tag, first);
		} else {
			document.head.appendChild(tag);
		}
	}

	function ytFlags(info) {
		var p = info.url.searchParams;
		var autoplay = p.get('autoplay') === '1';
		return { autoplay: autoplay, background: autoplay && p.get('mute') === '1' };
	}

	function processYouTubeIframe(iframe) {
		if (!iframe || iframe.getAttribute('data-kantan-video') === '1') {
			return;
		}
		if (iframe.id && processedIds[iframe.id]) {
			return; // a player library may recreate the iframe reusing the same id
		}
		var info = parseYouTube(iframe.src || iframe.getAttribute('src') || '');
		if (!info || info.special) {
			return;
		}
		// Muted-autoplay background loops (hero videos) inflate play rate; the
		// default setting skips them entirely.
		if (config.excludeAutoplay && ytFlags(info).background) {
			return;
		}
		if (tripBreaker()) {
			return;
		}
		iframe.setAttribute('data-kantan-video', '1');
		seq++;
		iframe.setAttribute('data-kantan-vid', String(seq));
		// The IFrame API routes its postMessage handshake by element id. An
		// existing iframe with no id is adopted but never wired up — it plays
		// normally yet emits no events. Guarantee a unique id before binding.
		if (!iframe.id) {
			iframe.id = 'kantan-yt-' + seq;
		}
		processedIds[iframe.id] = 1;

		// Make the embed API-controllable. A rewrite reloads the iframe; we do it
		// as early as possible (page load / on insertion) so it lands before the
		// visitor could have pressed play. Already-enabled embeds return null and
		// are bound without a reload.
		var rewritten = rewriteYouTubeSrc(iframe.src, { origin: config.origin, nocookie: config.nocookie });
		if (rewritten) {
			iframe.src = rewritten;
		}
		debug('process', { key: 'youtube:' + info.videoId, rewritten: !!rewritten, apiState: ytApiState });

		if (ytApiState === 'ready') {
			bindYouTubePlayer(iframe);
		} else {
			ytPending.push(iframe);
			loadYouTubeApi();
		}
	}

	function bindYouTubePlayer(iframe) {
		if (!root.YT || !root.YT.Player) {
			ytPending.push(iframe);
			return;
		}
		var info = parseYouTube(iframe.src) || { videoId: '', url: null };
		var videoKey = 'youtube:' + info.videoId;
		var tracker = createTracker('youtube', videoKey, { autoplay: info.url ? ytFlags(info).autoplay : false });
		trackers.push(tracker);
		var pollTimer = null;
		var player;

		function sample() {
			if (!player || !player.getCurrentTime) {
				return;
			}
			var duration = (player.getDuration && player.getDuration()) || 0;
			tracker.sample(player.getCurrentTime() || 0, duration);
		}
		function startPoll() {
			// The IFrame API has no native timeupdate; poll ~1/sec while playing.
			if (!pollTimer) {
				pollTimer = root.setInterval(sample, 1000);
			}
		}
		function stopPoll() {
			if (pollTimer) {
				root.clearInterval(pollTimer);
				pollTimer = null;
			}
		}
		function onReady() {
			debug('player:ready', videoKey);
			var data = player.getVideoData ? player.getVideoData() : {};
			tracker.impression((data && data.title) || '');
		}
		function onStateChange(e) {
			debug('player:state', { key: videoKey, state: e && e.data });
			var S = root.YT.PlayerState;
			if (e.data === S.PLAYING) {
				tracker.play();
				startPoll();
			} else if (e.data === S.PAUSED) {
				stopPoll();
				sample();
				tracker.flush('pause');
			} else if (e.data === S.ENDED) {
				stopPoll();
				tracker.complete(player.getCurrentTime ? player.getCurrentTime() : 0);
			}
		}
		function onError(e) {
			if (typeof console !== 'undefined') {
				console.warn('[KantanVideo] YT player error', e && e.data, 'for', videoKey);
			}
		}
		debug('bind', videoKey);
		try {
			player = new root.YT.Player(iframe, {
				events: { onReady: onReady, onStateChange: onStateChange, onError: onError }
			});
		} catch (e) {
			// Surface rather than swallow — a silent failure here is exactly the
			// "plays but no events" symptom we want to be able to diagnose.
			if (typeof console !== 'undefined') {
				console.warn('[KantanVideo] YT.Player binding threw for ' + videoKey, e);
			}
		}
	}

	/* ---- Vimeo adapter ---- */

	function loadVimeoApi() {
		if (vimeoApiState !== 'idle') {
			return;
		}
		vimeoApiState = 'loading';
		debug('api:load-vimeo', {});
		var tag = document.createElement('script');
		tag.src = 'https://player.vimeo.com/api/player.js';
		tag.onload = function () {
			vimeoApiState = 'ready';
			var queued = vimeoPending.slice();
			vimeoPending.length = 0;
			debug('api:ready-vimeo', { queued: queued.length });
			queued.forEach(bindVimeoPlayer);
		};
		document.head.appendChild(tag);
	}

	function processVimeoIframe(iframe) {
		if (!iframe || iframe.getAttribute('data-kantan-video') === '1') {
			return;
		}
		var info = parseVimeo(iframe.src || iframe.getAttribute('src') || '');
		if (!info) {
			return;
		}
		var p = info.url.searchParams;
		var autoplay = p.get('autoplay') === '1' || p.get('background') === '1';
		if (config.excludeAutoplay && (p.get('background') === '1' || (autoplay && p.get('muted') === '1'))) {
			return;
		}
		if (tripBreaker()) {
			return;
		}
		iframe.setAttribute('data-kantan-video', '1');
		seq++;
		iframe.setAttribute('data-kantan-vid', String(seq));
		debug('process', { key: 'vimeo:' + info.videoId, apiState: vimeoApiState });

		if (vimeoApiState === 'ready') {
			bindVimeoPlayer(iframe);
		} else {
			vimeoPending.push(iframe);
			loadVimeoApi();
		}
	}

	function bindVimeoPlayer(iframe) {
		if (!root.Vimeo || !root.Vimeo.Player) {
			vimeoPending.push(iframe);
			return;
		}
		var info = parseVimeo(iframe.src) || { videoId: '', url: null };
		var videoKey = 'vimeo:' + info.videoId;
		var autoplay = !!(info.url && info.url.searchParams.get('autoplay') === '1');
		var tracker = createTracker('vimeo', videoKey, { autoplay: autoplay });
		trackers.push(tracker);
		var lastSample = -1;
		var duration = 0;

		debug('bind', videoKey);
		var player;
		try {
			player = new root.Vimeo.Player(iframe);
		} catch (e) {
			if (typeof console !== 'undefined') {
				console.warn('[KantanVideo] Vimeo.Player binding threw for ' + videoKey, e);
			}
			return;
		}
		player.getVideoTitle().then(function (title) {
			debug('player:ready', videoKey);
			tracker.impression(title || '');
		}).catch(function () {
			tracker.impression('');
		});
		player.on('play', function () {
			tracker.play();
		});
		// Native timeupdate fires ~4×/sec; sample at most once per second-of-media.
		player.on('timeupdate', function (data) {
			duration = data.duration || duration;
			var whole = Math.floor(data.seconds);
			if (whole !== lastSample) {
				lastSample = whole;
				tracker.sample(data.seconds, duration);
			}
		});
		player.on('pause', function (data) {
			tracker.flush('pause');
		});
		player.on('ended', function (data) {
			tracker.complete(data && data.seconds);
		});
	}

	/* ---- HTML5 <video> adapter ---- */

	function processHtml5Video(el) {
		if (!el || el.getAttribute('data-kantan-video') === '1') {
			return;
		}
		var explicitId = el.getAttribute('data-kantan-video-id');
		var src = el.currentSrc || el.src || '';
		if (!src) {
			// Sources may attach late (lazy players); retry once metadata arrives.
			el.addEventListener('loadedmetadata', function retry() {
				el.removeEventListener('loadedmetadata', retry);
				processHtml5Video(el);
			});
			if (!explicitId) {
				return;
			}
		}
		var videoKey = html5Key(explicitId, src);
		if (!videoKey) {
			return;
		}
		var background = el.autoplay && el.muted;
		if (config.excludeAutoplay && background) {
			return;
		}
		if (tripBreaker()) {
			return;
		}
		el.setAttribute('data-kantan-video', '1');
		seq++;
		el.setAttribute('data-kantan-vid', String(seq));
		debug('process', { key: videoKey });

		var tracker = createTracker('html5', videoKey, { autoplay: el.autoplay });
		trackers.push(tracker);
		var lastSample = -1;

		function title() {
			var t = el.getAttribute('data-title') || '';
			if (!t) {
				var fig = el.closest && el.closest('figure');
				var cap = fig && fig.querySelector('figcaption');
				t = cap ? cap.textContent.replace(/\s+/g, ' ').trim() : '';
			}
			return t.slice(0, 200);
		}

		debug('bind', videoKey);
		tracker.impression(title());
		el.addEventListener('play', function () {
			tracker.play();
		});
		el.addEventListener('timeupdate', function () {
			var whole = Math.floor(el.currentTime);
			if (whole !== lastSample && !el.paused) {
				lastSample = whole;
				tracker.sample(el.currentTime, el.duration || 0);
			}
		});
		el.addEventListener('pause', function () {
			// 'pause' also fires right before 'ended'; complete() handles the
			// dedup, and an extra flush is harmless (deltas are already empty).
			tracker.flush('pause');
		});
		el.addEventListener('ended', function () {
			tracker.complete(el.currentTime);
		});
	}

	/* ---- Scan + observe ---- */

	function processElement(node) {
		if (node.tagName === 'IFRAME') {
			if (config.providers.youtube) {
				processYouTubeIframe(node);
			}
			if (config.providers.vimeo) {
				processVimeoIframe(node);
			}
		} else if (node.tagName === 'VIDEO' && config.providers.html5) {
			processHtml5Video(node);
		}
	}

	function scan(container) {
		var nodes = (container || document).querySelectorAll('iframe, video');
		debug('scan', { candidates: nodes.length });
		for (var i = 0; i < nodes.length; i++) {
			processElement(nodes[i]);
		}
	}

	function observe() {
		if (typeof MutationObserver === 'undefined') {
			return;
		}
		var mo = new MutationObserver(function (mutations) {
			for (var i = 0; i < mutations.length; i++) {
				var added = mutations[i].addedNodes;
				for (var j = 0; j < added.length; j++) {
					var node = added[j];
					if (node.nodeType !== 1) {
						continue;
					}
					// Discover elements directly — never call scan(), which emits a
					// debug event. Logging a debug event mutates the DOM (the trace
					// panel), which would re-trigger this observer and spin into an
					// infinite loop. The process* functions are idempotent (guarded
					// by data-kantan-video) and only log for real video embeds.
					if (node.tagName === 'IFRAME' || node.tagName === 'VIDEO') {
						processElement(node);
					} else if (node.querySelectorAll) {
						var found = node.querySelectorAll('iframe, video');
						for (var k = 0; k < found.length; k++) {
							processElement(found[k]);
						}
					}
				}
			}
		});
		observerRef = mo;
		mo.observe(document.documentElement || document.body, { childList: true, subtree: true });
	}

	function flushAll(trigger) {
		for (var i = 0; i < trackers.length; i++) {
			try { trackers[i].flush(trigger); } catch (e) {}
		}
		flushQueue();
	}

	function init(userConfig) {
		if (booted) {
			return;
		}
		booted = true;
		userConfig = userConfig || {};
		for (var k in userConfig) {
			if (Object.prototype.hasOwnProperty.call(userConfig, k)) {
				config[k] = userConfig[k];
			}
		}
		if (!config.origin) {
			config.origin = computeOrigin();
		}
		debug('init', { origin: config.origin, nocookie: config.nocookie });
		scan(document);
		observe();

		// Engagement leaves the page exactly once per hide/close — the same
		// sendBeacon reliability pattern the exit tracker uses.
		document.addEventListener('visibilitychange', function () {
			if (document.hidden) {
				flushAll('hidden');
			}
		});
		root.addEventListener('pagehide', function () {
			flushAll('unload');
		});
	}

	function autoboot() {
		if (typeof document === 'undefined') {
			return;
		}
		var cfg = (typeof root !== 'undefined' && root.kantanVideoConfig) || {};
		var start = function () { init(cfg); };
		if (document.readyState === 'loading') {
			document.addEventListener('DOMContentLoaded', start);
		} else {
			start();
		}
	}

	return {
		// Pure helpers (exported for tests).
		parseYouTube: parseYouTube,
		isYouTubeEmbed: isYouTubeEmbed,
		rewriteYouTubeSrc: rewriteYouTubeSrc,
		parseVimeo: parseVimeo,
		bucketOf: bucketOf,
		html5Key: html5Key,
		// Runtime.
		init: init,
		_autoboot: autoboot
	};
});
