/**
 * @license Copyright (c) 2017-2025, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md.
 */

import { BaseComponent } from './base-component';
import { html } from '../helpers/html-builder';
import { cx } from '../helpers/classnames';
import { takeLast } from '../helpers/take-last';

/**
 * Component for simulating browser devtools console.
 * Provides an API for logging messages with different severity levels.
 */
export class FakeDevtools extends BaseComponent {
	/**
	 * Attaches the FakeDevtools component to all matching elements on the page.
	 * It initializes the component for each element.
	 * This method should be called when the DOM is fully loaded.
	 */
	static attach() {
		const fakeDevtools = document.querySelectorAll( '.c-fake-devtools' );

		for ( const fakeDevtool of fakeDevtools ) {
			fakeDevtool.fakeDevtools = new FakeDevtools( fakeDevtool ).init();
		}
	}

	/**
	 * Creates the FakeDevtools component.
	 *
	 * @param element - The element containing the fake devtools.
	 */
	constructor( element ) {
		super( {
			logs: []
		} );

		this.elements = {
			root: element,
			logs: element.querySelector( '.c-fake-devtools__logs' )
		};

		this.config = {
			maxLogs: parseInt( element.dataset.maxLogs, 10 ) || 100
		};
	}

	/**
	 * Initializes the component by setting up the console view.
	 */
	init() {
		this.render();
		return this;
	}

	/**
	 * Clears the console logs.
	 */
	clear() {
		this.setState( {
			logs: []
		} );
	}

	/**
	 * Returns loggers for different severity levels.
	 */
	get loggers() {
		return {
			info: this._logInfo.bind( this ),
			warning: this._logWarning.bind( this ),
			error: this._logError.bind( this )
		};
	}

	/**
	 * Logs an info message to the console.
	 *
	 * @param args - Arguments to log.
	 */
	_logInfo( ...args ) {
		this._addLogEntry( 'info', args );
	}

	/**
	 * Logs a warning message to the console.
	 *
	 * @param args - Arguments to log.
	 */
	_logWarning( ...args ) {
		this._addLogEntry( 'warning', args );
	}

	/**
	 * Logs an error message to the console.
	 *
	 * @param args - Arguments to log.
	 */
	_logError( ...args ) {
		this._addLogEntry( 'error', args );
	}

	/**
	 * Adds a log entry to the console.
	 *
	 * @param type - The type of log (info, warning, error).
	 * @param args - The arguments to log.
	 * @private
	 */
	_addLogEntry( type, args ) {
		const logEntry = html.div( {
			class: cx( 'c-fake-devtools__log', `c-fake-devtools__log--${ type }` )
		} );

		// Add icon based on message type
		const iconName = ( () => {
			switch ( type ) {
				case 'info':
					return 'info-circle';
				case 'warning':
					return 'warning';
				default:
					return 'exclamation-circle';
			}
		} )();

		const iconElement = html[ 'svg-icon' ]( {
			icon: iconName,
			'aria-hidden': 'true',
			class: 'c-fake-devtools__log-icon'
		} );

		logEntry.appendChild( iconElement );

		// Create content wrapper for the log message
		const contentWrapper = html.div( {
			class: 'c-fake-devtools__log-content'
		} );

		logEntry.appendChild( contentWrapper );

		// Find the last text-based argument index
		const lastTextArgIndex = this._findLastTextArgIndex( args );

		let currentArgIndex = 0;
		while ( currentArgIndex < args.length ) {
			const currentArg = args[ currentArgIndex ];
			const isLastTextArg = currentArgIndex === lastTextArgIndex;

			if ( typeof currentArg === 'string' && currentArg.includes( '%c' ) ) {
				const consumedStylesCount = this._processFormattedLogArgument(
					contentWrapper,
					currentArg,
					args,
					currentArgIndex + 1,
					isLastTextArg
				);

				currentArgIndex += 1 + consumedStylesCount;
			} else {
				if ( typeof currentArg === 'object' && currentArg !== null ) {
					const objectViewer = html[ 'object-viewer' ]( {} );
					objectViewer.data = currentArg;
					contentWrapper.appendChild( objectViewer );
				} else {
					this._appendArgument( contentWrapper, currentArg, isLastTextArg );
				}

				currentArgIndex++;
			}

			if ( currentArgIndex < args.length ) {
				contentWrapper.appendChild( document.createTextNode( ' ' ) );
			}
		}

		const newLogs = [
			...this.state.logs,
			{ type, content: logEntry }
		];

		this.setState( {
			logs: takeLast( this.config.maxLogs, newLogs )
		} );
	}

	/**
	 * Finds the index of the last text-based argument in the args array.
	 *
	 * @param args - Arguments array to search.
	 * @returns The index of the last text-based argument, or -1 if none found.
	 * @private
	 */
	_findLastTextArgIndex( args ) {
		for ( let i = args.length - 1; i >= 0; i-- ) {
			const arg = args[ i ];
			if ( typeof arg === 'string' || ( typeof arg !== 'object' || arg === null ) ) {
				return i;
			}
		}
		return -1;
	}

	/**
	 * Processes a log argument that is a format string (contains %c).
	 * Appends styled segments to the logEntry.
	 *
	 * @param logEntry - The parent element for the log.
	 * @param formatString - The string containing %c specifiers.
	 * @param allArgs - All arguments passed to the logger.
	 * @param styleSearchStartIndex - The index in allArgs from which to start looking for style strings.
	 * @param isLastTextArg - Whether this is the last text-based argument.
	 * @returns The number of style arguments consumed.
	 * @private
	 */
	_processFormattedLogArgument( logEntry, formatString, allArgs, styleSearchStartIndex, isLastTextArg = false ) {
		const collectedStyleArgs = [];
		let styleArgsConsumedCount = 0;
		const cSpecifierCount = ( formatString.match( /%c/g ) || [] ).length;

		for ( let i = 0; i < cSpecifierCount; i++ ) {
			const potentialStyleArgIndex = styleSearchStartIndex + styleArgsConsumedCount;
			if ( potentialStyleArgIndex < allArgs.length && typeof allArgs[ potentialStyleArgIndex ] === 'string' ) {
				collectedStyleArgs.push( allArgs[ potentialStyleArgIndex ] );
				styleArgsConsumedCount++;
			} else {
				collectedStyleArgs.push( '' ); // Default to empty style
			}
		}

		const parts = formatString.split( '%c' );

		// Append the first part (text before the first %c) if it exists.
		// This part is unstyled.
		if ( parts[ 0 ] ) {
			this._appendFormattedText( logEntry, parts[ 0 ], false );
		}

		// Iterate over the remaining parts, which are the segments to be styled.
		// parts[1] gets style collectedStyleArgs[0]
		// parts[2] gets style collectedStyleArgs[1]
		// ...
		for ( let i = 1; i < parts.length; i++ ) {
			const segmentText = parts[ i ];
			// The style for parts[i] is collectedStyleArgs[i-1]
			const styleString = _createStyleString( collectedStyleArgs, i - 1 );
			const isLastSegment = i === parts.length - 1;

			// Only create a span if there's text or a style to apply.
			// An empty segment with a style will result in an empty styled span.
			if ( segmentText || styleString ) {
				const span = html.span();

				if ( segmentText ) {
					this._appendFormattedText( span, segmentText, isLastTextArg && isLastSegment );
				}

				if ( styleString ) {
					span.setAttribute( 'style', styleString );
				}

				logEntry.appendChild( span );
			}
		}
		return styleArgsConsumedCount;
	}

	/**
	 * Helper method to append formatted text with newlines support
	 *
	 * @param container - Element to append text to
	 * @param text - Text to format and append
	 * @param allowTruncation - Whether to allow text truncation
	 * @private
	 */
	_appendFormattedText( container, text, allowTruncation = false ) {
		if ( !text ) {
			return;
		}

		if ( allowTruncation ) {
			this._appendTextWithTruncation( container, text );
		} else {
			// Split text by newlines
			const lines = text.split( '\n' );

			lines.forEach( ( line, index ) => {
				if ( index > 0 ) {
					container.appendChild( html.br() );
				}
				container.appendChild( document.createTextNode( line ) );
			} );
		}
	}

	/**
	 * Helper method to append an argument to the log
	 * Handles URLs and plain text with newlines
	 *
	 * @param container - Element to append to
	 * @param arg - Argument to append
	 * @param allowTruncation - Whether to allow text truncation
	 * @private
	 */
	_appendArgument( container, arg, allowTruncation = false ) {
		const text = String( arg );

		// Check if text contains URLs
		if ( typeof arg === 'string' && ( arg.includes( 'http://' ) || arg.includes( 'https://' ) ) ) {
			// URL pattern matching both http:// and https://
			// The regex includes a capturing group, so split() will include the delimiters (URLs) in the result array.
			const urlRegex = /(https?:\/\/[^\s]+)/g;
			const segments = text.split( urlRegex );

			segments.forEach( ( segment, index ) => {
				if ( segment && segment.length > 0 ) {
					// Test if the current segment is a URL.
					if ( segment.match( urlRegex ) ) {
						const link = html.a( {
							href: segment,
							target: '_blank',
							rel: 'noopener noreferrer',
							class: cx( 'c-fake-devtools__link', 'b-link' ),
							tabindex: '-1'
						}, segment );

						container.appendChild( link );
					} else {
						// Only allow truncation on the last segment if this is the last text arg
						const isLastSegment = index === segments.length - 1;
						this._appendTextWithTruncation( container, segment, allowTruncation && isLastSegment );
					}
				}
			} );
		} else {
			// For text without URLs, just handle newlines and truncation
			this._appendTextWithTruncation( container, text, allowTruncation );
		}
	}

	/**
	 * Helper method to append text with truncation support
	 *
	 * @param container - Element to append text to
	 * @param text - Text to format and append
	 * @param allowTruncation - Whether to allow text truncation
	 * @private
	 */
	_appendTextWithTruncation( container, text, allowTruncation = false ) {
		if ( !text ) {
			return;
		}

		// Check if text is long enough to warrant truncation (considering newlines)
		const maxTextLength = 300;

		if ( allowTruncation && ( text.length > maxTextLength || text.split( '\n' ).length > 3 ) ) {
			const truncator = html[ 'log-text-truncator' ]( {
				'max-length': maxTextLength
			} );

			truncator.text = text;

			container.appendChild( truncator );
		} else {
			// For shorter text, use the existing method
			const textNode = html.span();
			this._appendFormattedText( textNode, text, false );
			container.appendChild( textNode );
		}
	}

	/**
	 * Renders the component based on the current state.
	 */
	render() {
		if ( !this.elements.logs ) {
			return;
		}

		this.elements.logs.innerHTML = '';

		// Sort logs: 'info' logs first, then others maintain their relative order.
		const sortedLogs = [ ...this.state.logs ].sort( ( a, b ) => {
			if ( a.type === 'info' && b.type !== 'info' ) {
				return -1;
			}

			if ( a.type !== 'info' && b.type === 'info' ) {
				return 1;
			}

			return 0;
		} );

		sortedLogs.forEach( log => {
			this.elements.logs.appendChild( log.content );
		} );
	}
}

/**
 * Log text truncator component for handling long text content
 */
class LogTextTruncator extends HTMLElement {
	static get observedAttributes() {
		return [ 'max-length' ];
	}

	constructor() {
		super();
		this._originalText = '';
		this._maxLength = 200;
		this._isExpanded = false;
	}

	connectedCallback() {
		this.render();
	}

	attributeChangedCallback( name, oldValue, newValue ) {
		if ( name === 'max-length' ) {
			this._maxLength = parseInt( newValue, 10 ) || 200;
			this.render();
		}
	}

	set text( value ) {
		this._originalText = String( value );
		this._isExpanded = false;

		if ( this.isConnected ) {
			this.render();
		}
	}

	get text() {
		return this._originalText;
	}

	render() {
		this.innerHTML = '';

		if ( this._originalText.length <= this._maxLength ) {
			this.appendChild( document.createTextNode( this._originalText ) );
			return;
		}

		const container = html.span( { class: 'c-log-text-truncator' } );

		if ( this._isExpanded ) {
			container.appendChild( document.createTextNode( this._originalText ) );

			const collapseButton = html.button( {
				class: 'b-reset-button c-log-text-truncator__expand-button',
				type: 'button'
			}, ' Show less' );

			collapseButton.addEventListener( 'click', () => {
				this._isExpanded = false;
				this.render();
			} );

			container.appendChild( collapseButton );
		} else {
			const truncatedText = this._originalText.substring( 0, this._maxLength );

			container.appendChild( document.createTextNode( truncatedText ) );
			container.appendChild( document.createTextNode( '…' ) );

			const expandButton = html.button( {
				class: 'b-reset-button c-log-text-truncator__expand-button',
				type: 'button'
			}, ' Show more' );

			expandButton.addEventListener( 'click', () => {
				this._isExpanded = true;
				this.render();
			} );

			container.appendChild( expandButton );
		}

		this.appendChild( container );
	}
}

customElements.define( 'log-text-truncator', LogTextTruncator );

/**
 * Object viewer component for displaying object structure similar to browser console
 */
class ObjectViewer extends HTMLElement {
	static get observedAttributes() {
		return [ 'auto-expand' ];
	}

	constructor() {
		super();
		this._data = null;
		this.autoExpand = false;
	}

	connectedCallback() {
		if ( this._data ) {
			this.render();
		}
	}

	attributeChangedCallback( name, oldValue, newValue ) {
		if ( name === 'auto-expand' ) {
			this.autoExpand = newValue !== null;
			if ( this._data ) {
				this.render();
			}
		}
	}

	set data( value ) {
		this._data = value;
		if ( this.isConnected ) {
			this.render();
		}
	}

	get data() {
		return this._data;
	}

	render() {
		this.innerHTML = '';

		const container = html.div( { class: 'c-object-viewer' } );
		this.renderObjectViewer( container, this._data, this.autoExpand );
		this.appendChild( container );
	}

	renderObjectViewer( container, data, initialExpanded = false ) {
		if ( typeof data !== 'object' || data === null ) {
			container.appendChild( this.formatPrimitiveValue( data ) );
			return;
		}

		const isArray = Array.isArray( data );
		const objectPreview = html.div( { class: 'c-object-viewer__item' } );

		// Create toggle and preview elements
		const toggleBtn = html.span( {
			class: initialExpanded ? 'c-object-viewer__toggle c-object-viewer__toggle--expanded' : 'c-object-viewer__toggle'
		} );

		objectPreview.appendChild( toggleBtn );

		// Create the preview content
		objectPreview.appendChild( this.createObjectPreview( data, isArray ) );
		container.appendChild( objectPreview );

		// Create expanded view
		const expandedView = html.div( {
			class: initialExpanded ? 'c-object-viewer__content' : 'c-object-viewer__content c-object-viewer__content--hidden'
		} );

		// Add properties to expanded view
		this.addPropertiesToExpandedView( expandedView, data, isArray );
		container.appendChild( expandedView );

		// Add toggle functionality
		this.setupToggleBehavior( toggleBtn, objectPreview, expandedView );
	}

	createObjectPreview( data, isArray ) {
		let previewContent = '';

		if ( isArray ) {
			previewContent = html.span( { class: 'c-object-viewer__preview' },
				'Array ',
				html.span( { class: 'c-object-viewer__array-bracket' }, '[' ),
				data.length > 0 ? ` ${ data.length } ` : ' ',
				html.span( { class: 'c-object-viewer__array-bracket' }, ']' )
			);
		} else {
			const keys = Object.keys( data );
			const keyElements = [];

			if ( keys.length > 0 ) {
				keys.slice( 0, 3 ).forEach( key => {
					keyElements.push( html.span( { class: 'c-object-viewer__key' }, key ) );
					keyElements.push( ', ' );
				} );

				// Remove the last comma
				if ( keyElements.length > 0 ) {
					keyElements.pop();
				}

				if ( keys.length > 3 ) {
					keyElements.push( ', …' );
				}
			}

			previewContent = html.span( { class: 'c-object-viewer__preview' },
				html.span( { class: 'c-object-viewer__brace' }, '{' ),
				...keyElements,
				html.span( { class: 'c-object-viewer__brace' }, '}' )
			);
		}

		return previewContent;
	}

	addPropertiesToExpandedView( expandedView, data, isArray ) {
		if ( isArray ) {
			data.forEach( ( item, index ) => {
				expandedView.appendChild(
					this.createPropertyElement( index, item )
				);
			} );
		} else {
			Object.entries( data ).forEach( ( [ key, value ] ) => {
				expandedView.appendChild(
					this.createPropertyElement( key, value )
				);
			} );
		}
	}

	createPropertyElement( key, value ) {
		const property = html.div( { class: 'c-object-viewer__property' } );
		const keySpan = html.span( { class: 'c-object-viewer__key' }, `${ key }` );
		property.appendChild( keySpan );

		if ( typeof value === 'object' && value !== null ) {
			this.renderObjectViewer( property, value, false );
		} else {
			property.appendChild( this.formatPrimitiveValue( value ) );
		}

		return property;
	}

	formatPrimitiveValue( value ) {
		let className = '';
		let textContent = '';

		switch ( typeof value ) {
			case 'string':
				className = 'c-object-viewer__string';
				textContent = `"${ value }"`;
				break;

			case 'number':
				className = 'c-object-viewer__number';
				textContent = String( value );
				break;

			case 'boolean':
				className = 'c-object-viewer__boolean';
				textContent = String( value );
				break;

			case 'function':
				className = 'c-object-viewer__function';
				textContent = 'function()';
				break;

			case 'undefined':
				className = 'c-object-viewer__undefined';
				textContent = 'undefined';
				break;

			case 'object':
				if ( value instanceof Date ) {
					className = 'c-object-viewer__date';
					textContent = value.toString();
				} else if ( value === null ) {
					className = 'c-object-viewer__null';
					textContent = 'null';
				} else {
					className = 'c-object-viewer__preview';
					textContent = String( value );
				}
				break;
			default:
				className = 'c-object-viewer__preview';
				textContent = String( value );
		}

		return html.span( { class: className }, textContent );
	}

	setupToggleBehavior( toggleBtn, objectPreview, expandedView ) {
		const toggleExpansion = () => {
			toggleBtn.classList.toggle( 'c-object-viewer__toggle--expanded' );
			expandedView.classList.toggle( 'c-object-viewer__content--hidden' );
		};

		toggleBtn.addEventListener( 'click', e => {
			e.stopPropagation();
			toggleExpansion();
		} );

		objectPreview.addEventListener( 'click', toggleExpansion );
	}
}

customElements.define( 'object-viewer', ObjectViewer );

/**
 * Creates a style string from an array of style arguments at a given index.
 *
 * @param styleArgs - Array of style strings.
 * @param index - Index of the style to use.
 * @returns The style string or an empty string if not applicable.
 * @private
 */
function _createStyleString( styleArgs, index ) {
	if ( index < styleArgs.length && styleArgs[ index ] ) {
		return styleArgs[ index ];
	}
	return '';
}
