<?php
/**
 * Format parser layer: turns JSON, XML, RSS/Atom, or CSV into the same
 * associative-array shape the rest of the pipeline consumes. Everything
 * downstream — detection, streams, display, posts — never knows the
 * difference.
 *
 * @package DataStream
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class DataStream_Parser
 */
class DataStream_Parser {

	/**
	 * Supported explicit formats ('auto' sniffs).
	 */
	public const FORMATS = array( 'auto', 'json', 'xml', 'rss', 'csv' );

	/**
	 * Parse a response body into an array.
	 *
	 * @param string $body         Raw body (BOM handled here).
	 * @param string $content_type Content-Type header value ('' if unknown).
	 * @param string $format_hint  Per-stream format ('auto' = sniff).
	 * @return array{format: string, data: array<string, mixed>}|null Null when unparseable.
	 */
	public static function parse( $body, $content_type = '', $format_hint = 'auto' ) {
		$body = trim( (string) $body );

		if ( 0 === strpos( $body, "\xEF\xBB\xBF" ) ) {
			$body = substr( $body, 3 );
			$body = trim( $body );
		}

		if ( '' === $body ) {
			return null;
		}

		$format = self::sniff_format( $body, $content_type, $format_hint );

		switch ( $format ) {
			case 'json':
				$data = json_decode( $body, true, 32, JSON_BIGINT_AS_STRING );
				return is_array( $data ) ? array(
					'format' => 'json',
					'data'   => $data,
				) : null;

			case 'rss':
				$data = self::parse_feed( $body );
				return null !== $data ? array(
					'format' => 'rss',
					'data'   => $data,
				) : null;

			case 'xml':
				$data = self::parse_xml( $body );
				return null !== $data ? array(
					'format' => 'xml',
					'data'   => $data,
				) : null;

			case 'csv':
				$data = self::parse_csv( $body );
				return null !== $data ? array(
					'format' => 'csv',
					'data'   => $data,
				) : null;
		}

		return null;
	}

	/**
	 * Decide the format: explicit hint > content type > body shape.
	 *
	 * @param string $body         Trimmed body.
	 * @param string $content_type Content-Type header.
	 * @param string $format_hint  Stream setting.
	 * @return string
	 */
	public static function sniff_format( $body, $content_type, $format_hint ) {
		$format_hint = strtolower( (string) $format_hint );

		if ( in_array( $format_hint, self::FORMATS, true ) && 'auto' !== $format_hint ) {
			return $format_hint;
		}

		$first = substr( $body, 0, 1 );

		if ( '{' === $first || '[' === $first ) {
			return 'json';
		}

		if ( '<' === $first ) {
			return self::looks_like_feed( $body ) ? 'rss' : 'xml';
		}

		$content_type = strtolower( (string) $content_type );

		if ( false !== strpos( $content_type, 'json' ) ) {
			return 'json';
		}

		if ( false !== strpos( $content_type, 'csv' ) || false !== strpos( $content_type, 'tab-separated' ) ) {
			return 'csv';
		}

		if ( false !== strpos( $content_type, 'xml' ) ) {
			return self::looks_like_feed( $body ) ? 'rss' : 'xml';
		}

		// Last resort: multiple lines sharing a delimiter count reads as CSV.
		return self::looks_like_csv( $body ) ? 'csv' : 'json';
	}

	/**
	 * Whether an XML body is an RSS/Atom feed.
	 *
	 * @param string $body XML body.
	 * @return bool
	 */
	private static function looks_like_feed( $body ) {
		$head = substr( $body, 0, 512 );
		return 1 === preg_match( '/<(rss|feed|rdf:RDF)[\s>]/', $head );
	}

	/**
	 * Whether a plain-text body plausibly is delimited rows.
	 *
	 * @param string $body Body.
	 * @return bool
	 */
	private static function looks_like_csv( $body ) {
		$lines = preg_split( '/\r\n|\r|\n/', $body, 3 );

		if ( count( $lines ) < 2 ) {
			return false;
		}

		$delimiter = self::sniff_delimiter( $lines[0] );

		return substr_count( $lines[0], $delimiter ) > 0
			&& substr_count( $lines[0], $delimiter ) === substr_count( $lines[1], $delimiter );
	}

	/**
	 * XXE-safe XML → array. Attributes become "@name" keys; repeated child
	 * elements become lists; text-only nodes become strings.
	 *
	 * @param string $body XML body.
	 * @return array<string, mixed>|null
	 */
	public static function parse_xml( $body ) {
		$previous = libxml_use_internal_errors( true );

		// LIBXML_NONET blocks network fetches; entity substitution is never
		// enabled, so external-entity (XXE) payloads stay inert.
		$xml = simplexml_load_string( $body, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NONET );

		libxml_clear_errors();
		libxml_use_internal_errors( $previous );

		if ( false === $xml ) {
			return null;
		}

		$converted = self::element_to_array( $xml );

		return is_array( $converted ) ? $converted : array( 'value' => $converted );
	}

	/**
	 * Convert one SimpleXML element.
	 *
	 * @param SimpleXMLElement $element Element.
	 * @return array<string, mixed>|string
	 */
	private static function element_to_array( $element ) {
		$result = array();

		foreach ( $element->attributes() as $name => $value ) {
			$result[ '@' . $name ] = (string) $value;
		}

		$children = array();

		foreach ( $element->children() as $name => $child ) {
			$children[ $name ][] = self::element_to_array( $child );
		}

		foreach ( $children as $name => $items ) {
			$result[ $name ] = 1 === count( $items ) ? $items[0] : $items;
		}

		$text = trim( (string) $element );

		if ( empty( $children ) ) {
			if ( empty( $result ) ) {
				return $text;
			}

			if ( '' !== $text ) {
				$result['#text'] = $text;
			}
		}

		return $result;
	}

	/**
	 * RSS 2.0 / Atom → a list of normalized records under 'items', keeping
	 * feed metadata alongside. Curated keys: id, title, link, date, content,
	 * author — plus every original element.
	 *
	 * @param string $body Feed XML.
	 * @return array<string, mixed>|null
	 */
	public static function parse_feed( $body ) {
		$tree = self::parse_xml( $body );

		if ( null === $tree || ! is_array( $tree ) ) {
			return null;
		}

		$raw_items = array();
		$feed_meta = array();

		if ( isset( $tree['channel'] ) && is_array( $tree['channel'] ) ) {
			// RSS 2.0.
			$channel   = $tree['channel'];
			$raw_items = $channel['item'] ?? array();
			unset( $channel['item'] );
			$feed_meta = $channel;
		} elseif ( isset( $tree['entry'] ) ) {
			// Atom (root <feed> becomes the tree itself).
			$raw_items = $tree['entry'];
			$feed_meta = array_diff_key( $tree, array( 'entry' => 1 ) );
		}

		if ( ! empty( $raw_items ) && ! isset( $raw_items[0] ) ) {
			$raw_items = array( $raw_items );
		}

		$items = array();

		foreach ( (array) $raw_items as $raw ) {
			if ( ! is_array( $raw ) ) {
				continue;
			}

			$link = $raw['link'] ?? '';

			// Atom links are elements with attributes.
			if ( is_array( $link ) ) {
				if ( isset( $link['@href'] ) ) {
					$link = $link['@href'];
				} elseif ( isset( $link[0]['@href'] ) ) {
					$link = $link[0]['@href'];
				} else {
					$link = '';
				}
			}

			$normalized = array(
				'id'      => self::first_scalar( $raw, array( 'guid', 'id', 'link' ) ) ?: (string) $link,
				'title'   => self::first_scalar( $raw, array( 'title' ) ),
				'link'    => (string) $link,
				'date'    => self::first_scalar( $raw, array( 'pubDate', 'published', 'updated', 'date' ) ),
				'content' => self::first_scalar( $raw, array( 'description', 'summary', 'content', 'encoded' ) ),
				'author'  => self::first_scalar( $raw, array( 'author', 'creator' ) ),
			);

			$items[] = array_merge( $raw, array_filter( $normalized, 'strlen' ) );
		}

		return array(
			'feed'  => $feed_meta,
			'items' => $items,
		);
	}

	/**
	 * First scalar-ish value among candidate keys.
	 *
	 * @param array<string, mixed> $item Item.
	 * @param array<int, string>   $keys Candidate keys.
	 * @return string
	 */
	private static function first_scalar( $item, $keys ) {
		foreach ( $keys as $key ) {
			if ( ! isset( $item[ $key ] ) ) {
				continue;
			}

			$value = $item[ $key ];

			if ( is_array( $value ) ) {
				$value = $value['#text'] ?? ( $value[0] ?? '' );
				if ( is_array( $value ) ) {
					$value = $value['#text'] ?? '';
				}
			}

			if ( is_scalar( $value ) && '' !== trim( (string) $value ) ) {
				return trim( (string) $value );
			}
		}

		return '';
	}

	/**
	 * CSV/TSV → records under 'items'. Header row becomes the keys.
	 * Quoted fields (including embedded newlines) handled by fgetcsv.
	 *
	 * @param string $body CSV body.
	 * @return array<string, mixed>|null
	 */
	public static function parse_csv( $body ) {
		$first_line = (string) strtok( $body, "\r\n" );
		$delimiter  = self::sniff_delimiter( $first_line );

		// phpcs:disable WordPress.WP.AlternativeFunctions -- php://temp is an
		// in-memory stream (never the filesystem); fgetcsv needs a handle and
		// WP_Filesystem has no equivalent for quoted-multiline CSV parsing.
		$handle = fopen( 'php://temp', 'r+' );

		if ( false === $handle ) {
			return null;
		}

		fwrite( $handle, $body );
		rewind( $handle );

		$header = fgetcsv( $handle, 0, $delimiter, '"', '\\' );

		if ( false === $header || count( $header ) < 1 ) {
			fclose( $handle );
			return null;
		}

		$header = array_map( static function ( $cell ) {
			return trim( (string) $cell );
		}, $header );

		// A header of one unnamed column is not a table.
		if ( 1 === count( $header ) && '' === $header[0] ) {
			fclose( $handle );
			return null;
		}

		$items = array();

		while ( false !== ( $row = fgetcsv( $handle, 0, $delimiter, '"', '\\' ) ) ) {
			if ( array( null ) === $row ) {
				continue; // Blank line.
			}

			$item = array();

			foreach ( $header as $index => $name ) {
				if ( '' === $name ) {
					$name = 'column_' . ( $index + 1 );
				}
				$item[ $name ] = isset( $row[ $index ] ) ? (string) $row[ $index ] : '';
			}

			$items[] = $item;
		}

		fclose( $handle );
		// phpcs:enable WordPress.WP.AlternativeFunctions

		if ( empty( $items ) ) {
			return null;
		}

		return array( 'items' => $items );
	}

	/**
	 * Pick the most frequent delimiter in the header line.
	 *
	 * @param string $line First line.
	 * @return string
	 */
	private static function sniff_delimiter( $line ) {
		$best       = ',';
		$best_count = -1;

		foreach ( array( ',', ';', "\t", '|' ) as $candidate ) {
			$count = substr_count( $line, $candidate );

			if ( $count > $best_count ) {
				$best_count = $count;
				$best       = $candidate;
			}
		}

		return $best;
	}
}
