<?php
/**
 * The detection engine: answers configuration questions by inspecting a real
 * API response instead of asking the user.
 *
 * Every detector returns its suggestion together with a plain-English
 * evidence sentence, so the UI can teach ("`sku` is unique across all 20
 * items") instead of mystifying.
 *
 * @package DataStream
 */

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

/**
 * Class DataStream_Detector
 */
class DataStream_Detector {

	/**
	 * Maximum tree depth searched for the items array.
	 */
	private const MAX_DEPTH = 4;

	/**
	 * Items sampled for field/id scoring.
	 */
	private const SAMPLE = 10;

	/**
	 * Run every response-based detector.
	 *
	 * @param array<string, mixed>  $data    Decoded response body.
	 * @param array<string, mixed>  $headers Response headers (lowercase keys preferred).
	 * @return array<string, mixed> {
	 *     items_path, items, single_object, id_field, id_source, fields,
	 *     suggested_fields, pagination, evidence[]
	 * }
	 */
	public static function detect( $data, $headers = array() ) {
		$evidence = array();

		list( $items_path, $items, $single ) = self::locate_items( $data );

		if ( $single ) {
			$evidence[] = __( 'The response is a single record, not a list — it will sync as one item.', 'datastream-api-sync' );
		} elseif ( '' !== $items_path ) {
			$evidence[] = sprintf(
				/* translators: 1: number of records, 2: dot path */
				__( 'Found %1$d records under "%2$s".', 'datastream-api-sync' ),
				count( $items ),
				$items_path
			);
		} else {
			$evidence[] = sprintf(
				/* translators: %d: number of records */
				__( 'Found %d records at the top level of the response.', 'datastream-api-sync' ),
				count( $items )
			);
		}

		$id = self::score_id_field( $items );

		if ( 'hash' === $id['source'] ) {
			$evidence[] = __( 'No field is unique across every record, so records will be identified by a fingerprint of their content.', 'datastream-api-sync' );
		} else {
			$evidence[] = sprintf(
				/* translators: 1: field name, 2: number of records */
				__( '"%1$s" is unique across all %2$d records — using it as the ID.', 'datastream-api-sync' ),
				$id['field'],
				count( $items )
			);
		}

		$fields     = self::type_fields( $items );
		$pagination = self::sniff_pagination( $data, $headers, $items_path );

		if ( 'none' !== $pagination['mode'] ) {
			$evidence[] = $pagination['evidence'];
		}

		return array(
			'items_path'       => $items_path,
			'item_count'       => count( $items ),
			'single_object'    => $single,
			'sample'           => $items[0] ?? null,
			'samples'          => array_slice( $items, 0, 5 ),
			'id_field'         => $id['field'],
			'id_source'        => $id['source'],
			'fields'           => $fields,
			'suggested_fields' => self::suggest_display_fields( $fields ),
			'pagination'       => $pagination,
			'evidence'         => $evidence,
		);
	}

	/**
	 * Find the most plausible list of records anywhere in the response.
	 *
	 * Scores every sequential array of similarly-shaped objects by
	 * count × key-overlap similarity. No hardcoded wrapper names.
	 *
	 * @param array<string, mixed> $data Decoded response.
	 * @return array{0: string, 1: array<int, array<string, mixed>>, 2: bool} Path, items, single-object flag.
	 */
	public static function locate_items( $data ) {
		$candidates = array();
		self::walk_for_lists( $data, '', 0, $candidates );

		if ( ! empty( $candidates ) ) {
			usort( $candidates, static function ( $a, $b ) {
				return $b['score'] <=> $a['score'];
			} );

			return array( $candidates[0]['path'], $candidates[0]['items'], false );
		}

		// No list anywhere: a root object with scalar members is one record.
		if ( is_array( $data ) && ! empty( $data ) ) {
			foreach ( $data as $value ) {
				if ( is_scalar( $value ) || null === $value ) {
					return array( '', array( $data ), true );
				}
			}
		}

		return array( '', array(), false );
	}

	/**
	 * Recursively collect candidate record lists.
	 *
	 * @param mixed                             $node       Current node.
	 * @param string                            $path       Dot path to here.
	 * @param int                               $depth      Current depth.
	 * @param array<int, array<string, mixed>>  $candidates Accumulator.
	 */
	private static function walk_for_lists( $node, $path, $depth, &$candidates ) {
		if ( ! is_array( $node ) || $depth > self::MAX_DEPTH ) {
			return;
		}

		if ( self::is_record_list( $node ) ) {
			$items = array_values( array_filter( $node, 'is_array' ) );
			$candidates[] = array(
				'path'  => $path,
				'items' => $items,
				'score' => count( $items ) * self::shape_similarity( $items ) * self::name_weight( $path ),
			);
			// A list of records is a leaf for our purposes.
			return;
		}

		foreach ( $node as $key => $value ) {
			if ( is_int( $key ) ) {
				continue;
			}

			self::walk_for_lists( $value, '' === $path ? (string) $key : $path . '.' . $key, $depth + 1, $candidates );
		}
	}

	/**
	 * Weight a candidate by its key name: arrays under metadata-convention
	 * names (links, meta, errors…) are almost never the records — a
	 * pagination links array once outscored the actual data on count alone.
	 *
	 * @param string $path Dot path of the candidate.
	 * @return float
	 */
	private static function name_weight( $path ) {
		$leaf = strtolower( (string) preg_replace( '/^.*\./', '', $path ) );

		if ( in_array( $leaf, array( 'links', 'meta', 'pagination', 'paging', 'errors', 'warnings', 'facets' ), true ) ) {
			return 0.25;
		}

		if ( in_array( $leaf, array( 'data', 'items', 'results', 'records', 'entries', 'docs', 'rows', 'list' ), true ) ) {
			return 1.5;
		}

		return 1.0;
	}

	/**
	 * Whether an array is a sequential list of assoc-array records.
	 *
	 * @param array<mixed> $node Candidate.
	 * @return bool
	 */
	private static function is_record_list( $node ) {
		if ( array() === $node || array_keys( $node ) !== range( 0, count( $node ) - 1 ) ) {
			return false;
		}

		$records = 0;

		foreach ( array_slice( $node, 0, self::SAMPLE ) as $item ) {
			if ( is_array( $item ) && ! empty( $item ) && ! isset( $item[0] ) ) {
				++$records;
			}
		}

		return $records > 0 && $records >= (int) ceil( count( array_slice( $node, 0, self::SAMPLE ) ) / 2 );
	}

	/**
	 * Average key-overlap (Jaccard) between the first item and the rest.
	 *
	 * @param array<int, array<string, mixed>> $items Items.
	 * @return float 0..1
	 */
	private static function shape_similarity( $items ) {
		if ( count( $items ) < 2 ) {
			return 1.0;
		}

		$first = array_keys( $items[0] );
		$sum   = 0.0;
		$n     = 0;

		foreach ( array_slice( $items, 1, self::SAMPLE ) as $item ) {
			$keys  = array_keys( $item );
			$inter = count( array_intersect( $first, $keys ) );
			$union = count( array_unique( array_merge( $first, $keys ) ) );
			$sum  += $union > 0 ? $inter / $union : 0;
			++$n;
		}

		return $n > 0 ? $sum / $n : 1.0;
	}

	/**
	 * Pick the ID field: must be unique across every sampled item; ties break
	 * on name affinity, then value brevity.
	 *
	 * @param array<int, array<string, mixed>> $items Items.
	 * @return array{field: string, source: string} source: field|hash.
	 */
	public static function score_id_field( $items ) {
		if ( empty( $items ) ) {
			return array(
				'field'  => 'id',
				'source' => 'field',
			);
		}

		$sampled    = array_slice( $items, 0, self::SAMPLE * 3 );
		$affinities = array(
			'id'   => 6,
			'uuid' => 5,
			'key'  => 4,
			'slug' => 4,
			'sku'  => 4,
			'code' => 3,
		);

		$best       = null;
		$best_score = -1;

		foreach ( array_keys( $sampled[0] ) as $key ) {
			$values = array();

			foreach ( $sampled as $item ) {
				if ( ! array_key_exists( $key, $item ) || ( ! is_scalar( $item[ $key ] ) && null !== $item[ $key ] ) ) {
					continue 2;
				}
				$values[] = (string) $item[ $key ];
			}

			if ( count( array_unique( $values ) ) !== count( $values ) ) {
				continue;
			}

			$lower = strtolower( (string) $key );
			$score = $affinities[ $lower ] ?? 0;

			if ( 0 === $score && 1 === preg_match( '/(_id|Id|ID)$/', (string) $key ) ) {
				$score = 3;
			}

			$avg_len = array_sum( array_map( 'strlen', $values ) ) / max( 1, count( $values ) );
			$score  += $avg_len <= 40 ? 1 : 0;

			if ( $score > $best_score ) {
				$best_score = $score;
				$best       = (string) $key;
			}
		}

		if ( null !== $best && $best_score > 0 ) {
			return array(
				'field'  => $best,
				'source' => 'field',
			);
		}

		// A unique-but-anonymous field is still better than nothing.
		if ( null !== $best ) {
			return array(
				'field'  => $best,
				'source' => 'field',
			);
		}

		return array(
			'field'  => '',
			'source' => 'hash',
		);
	}

	/**
	 * Detect a display type for every scalar field (one nested level too).
	 *
	 * @param array<int, array<string, mixed>> $items Items.
	 * @return array<string, array{type: string, sample: string}>
	 */
	public static function type_fields( $items ) {
		$fields  = array();
		$sampled = array_slice( $items, 0, self::SAMPLE );

		if ( empty( $sampled ) ) {
			return $fields;
		}

		foreach ( $sampled[0] as $key => $value ) {
			if ( is_scalar( $value ) || null === $value ) {
				$fields[ (string) $key ] = self::type_of_column( $sampled, (string) $key );
			} elseif ( is_array( $value ) && ! isset( $value[0] ) ) {
				foreach ( $value as $sub_key => $sub_value ) {
					if ( is_scalar( $sub_value ) || null === $sub_value ) {
						$path            = $key . '.' . $sub_key;
						$fields[ $path ] = self::type_of_column( $sampled, $path );
					}

					if ( count( $fields ) >= 40 ) {
						break 2;
					}
				}
			}

			if ( count( $fields ) >= 40 ) {
				break;
			}
		}

		return $fields;
	}

	/**
	 * Type one column by sampling its values.
	 *
	 * @param array<int, array<string, mixed>> $items Sampled items.
	 * @param string                           $path  Field path.
	 * @return array{type: string, sample: string}
	 */
	private static function type_of_column( $items, $path ) {
		$values = array();

		foreach ( $items as $item ) {
			$value = DataStream_Renderer::pluck( $item, $path );

			if ( is_scalar( $value ) && '' !== (string) $value ) {
				$values[] = $value;
			}
		}

		$sample = isset( $values[0] ) ? (string) $values[0] : '';

		if ( empty( $values ) ) {
			return array(
				'type'   => 'text',
				'sample' => '',
			);
		}

		$all = static function ( callable $test ) use ( $values ) {
			foreach ( $values as $value ) {
				if ( ! $test( $value ) ) {
					return false;
				}
			}
			return true;
		};

		if ( $all( 'is_bool' ) ) {
			return array(
				'type'   => 'bool',
				'sample' => $sample,
			);
		}

		if ( $all( 'is_numeric' ) ) {
			return array(
				'type'   => 'number',
				'sample' => $sample,
			);
		}

		$is_url = static function ( $v ) {
			return is_string( $v ) && 1 === preg_match( '#^https?://\S+$#i', $v );
		};

		if ( $all( $is_url ) ) {
			$is_image = static function ( $v ) {
				return 1 === preg_match( '#\.(jpe?g|png|gif|webp|avif|svg)(\?|$)#i', (string) $v );
			};

			return array(
				'type'   => $all( $is_image ) ? 'image' : 'url',
				'sample' => $sample,
			);
		}

		$is_email = static function ( $v ) {
			return is_string( $v ) && false !== filter_var( $v, FILTER_VALIDATE_EMAIL );
		};

		if ( $all( $is_email ) ) {
			return array(
				'type'   => 'email',
				'sample' => $sample,
			);
		}

		$is_date = static function ( $v ) {
			if ( ! is_string( $v ) || 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}([T ]|$)/', $v ) ) {
				return false;
			}
			return false !== strtotime( $v );
		};

		if ( $all( $is_date ) ) {
			return array(
				'type'   => 'date',
				'sample' => $sample,
			);
		}

		$is_html = static function ( $v ) {
			// wp_strip_all_tags() also trims, so compare against the trimmed value.
			return is_string( $v ) && trim( $v ) !== wp_strip_all_tags( $v );
		};

		if ( $all( $is_html ) ) {
			return array(
				'type'   => 'html',
				'sample' => $sample,
			);
		}

		$avg_len = array_sum( array_map( static function ( $v ) {
			return strlen( (string) $v );
		}, $values ) ) / count( $values );

		return array(
			'type'   => $avg_len > 120 ? 'longtext' : 'text',
			'sample' => $sample,
		);
	}

	/**
	 * Choose the 3–4 fields worth displaying by default: a title-like text
	 * field first, then short scalars.
	 *
	 * @param array<string, array{type: string, sample: string}> $fields Typed fields.
	 * @return array<int, string> Field specs including format suffixes.
	 */
	public static function suggest_display_fields( $fields ) {
		$title_names = array( 'title', 'name', 'label', 'headline', 'fact', 'question', 'common' );
		$chosen      = array();

		foreach ( $fields as $path => $info ) {
			$leaf = strtolower( (string) preg_replace( '/^.*\./', '', $path ) );

			if ( in_array( $info['type'], array( 'text', 'longtext' ), true ) && in_array( $leaf, $title_names, true ) ) {
				$chosen[] = $path;
				break;
			}
		}

		// An image field earns a slot early — image-led layouts sell the data.
		foreach ( $fields as $path => $info ) {
			if ( 'image' === $info['type'] ) {
				$chosen[] = $path . ':image';
				break;
			}
		}

		foreach ( $fields as $path => $info ) {
			if ( count( $chosen ) >= 4 ) {
				break;
			}

			if ( in_array( $path, $chosen, true ) || 'longtext' === $info['type'] || 'html' === $info['type'] ) {
				continue;
			}

			$suffix   = in_array( $info['type'], array( 'image', 'url', 'date', 'number', 'bool' ), true ) ? ':' . $info['type'] : '';
			$chosen[] = $path . $suffix;
		}

		if ( empty( $chosen ) && ! empty( $fields ) ) {
			$chosen[] = (string) array_key_first( $fields );
		}

		return $chosen;
	}

	/**
	 * Detect pagination from headers and body signals.
	 *
	 * @param array<string, mixed> $data       Decoded response.
	 * @param array<string, mixed> $headers    Response headers.
	 * @param string               $items_path Detected items path.
	 * @return array{mode: string, page_param: string, per_page_param: string, evidence: string}
	 */
	public static function sniff_pagination( $data, $headers, $items_path ) {
		$none = array(
			'mode'           => 'none',
			'page_param'     => '',
			'per_page_param' => '',
			'evidence'       => '',
		);

		$link = '';
		foreach ( $headers as $name => $value ) {
			if ( 'link' === strtolower( (string) $name ) ) {
				$link = is_array( $value ) ? implode( ', ', $value ) : (string) $value;
			}
		}

		if ( '' !== $link && false !== stripos( $link, 'rel="next"' ) ) {
			$param = 1 === preg_match( '/[?&](page|offset|p)=\d+[^>]*>;\s*rel="next"/i', $link, $m ) ? strtolower( $m[1] ) : 'page';

			return array(
				'mode'           => 'offset' === $param ? 'offset' : 'page',
				'page_param'     => $param,
				'per_page_param' => '',
				'evidence'       => __( 'The API advertises more pages in its Link header.', 'datastream-api-sync' ),
			);
		}

		// Look at the wrapper level (siblings of the items array), the root,
		// and one level into metadata-ish sibling objects (info/meta/paging…)
		// — APIs like Rick & Morty keep `next` inside an `info` object.
		$scopes = array( $data );

		if ( false !== strpos( $items_path, '.' ) ) {
			$parent = DataStream_Renderer::pluck( $data, (string) preg_replace( '/\.[^.]+$/', '', $items_path ) );
			if ( is_array( $parent ) ) {
				$scopes[] = $parent;
			}
		}

		foreach ( $scopes as $scope ) {
			if ( ! is_array( $scope ) ) {
				continue;
			}

			foreach ( $scope as $child ) {
				if ( is_array( $child ) && ! isset( $child[0] ) && ! empty( $child ) && count( $scopes ) < 8 ) {
					$scopes[] = $child;
				}
			}
		}

		foreach ( $scopes as $scope ) {
			if ( ! is_array( $scope ) ) {
				continue;
			}

			$keys = array_map( 'strtolower', array_map( 'strval', array_keys( $scope ) ) );

			foreach ( array( 'next_cursor', 'cursor' ) as $cursor_key ) {
				if ( in_array( $cursor_key, $keys, true ) ) {
					$none['evidence'] = __( 'This API uses cursor pagination, which DataStream does not follow yet — only the first page will sync.', 'datastream-api-sync' );
					return $none;
				}
			}

			$page_signals = array( 'total_pages', 'totalpages', 'pages', 'last_page', 'page_count' );

			foreach ( $page_signals as $signal ) {
				if ( in_array( $signal, $keys, true ) ) {
					return array(
						'mode'           => 'page',
						'page_param'     => 'page',
						'per_page_param' => in_array( 'per_page', $keys, true ) ? 'per_page' : '',
						'evidence'       => sprintf(
							/* translators: %s: response key */
							__( 'The response reports "%s" — there are more pages to fetch.', 'datastream-api-sync' ),
							$signal
						),
					);
				}
			}

			foreach ( array( 'next', 'next_page', 'nextpage' ) as $next_key ) {
				$idx = array_search( $next_key, $keys, true );

				if ( false !== $idx ) {
					$next_value = array_values( $scope )[ $idx ];

					if ( is_string( $next_value ) && 1 === preg_match( '/[?&](page|offset)=/i', $next_value, $m ) ) {
						$param = strtolower( $m[1] );
						return array(
							'mode'           => 'offset' === $param ? 'offset' : 'page',
							'page_param'     => $param,
							'per_page_param' => '',
							'evidence'       => __( 'The response links to a next page — pagination enabled.', 'datastream-api-sync' ),
						);
					}
				}
			}

			if ( in_array( 'offset', $keys, true ) && in_array( 'limit', $keys, true ) ) {
				return array(
					'mode'           => 'offset',
					'page_param'     => 'offset',
					'per_page_param' => 'limit',
					'evidence'       => __( 'The response echoes offset/limit — offset pagination enabled.', 'datastream-api-sync' ),
				);
			}
		}

		return $none;
	}

	/**
	 * Given a key, find where it belongs by trying the standard slots and
	 * reading only status codes. First 2xx wins.
	 *
	 * @param string   $url     Endpoint URL.
	 * @param string   $key     The pasted credential.
	 * @param callable $request function( string $url, array $headers ): array|WP_Error.
	 * @return array{slot: string, patch: array<string, string>, evidence: string}|null
	 */
	public static function probe_auth( $url, $key, callable $request ) {
		$key   = trim( (string) $key );
		$slots = array(
			array(
				'slot'     => 'bearer',
				'headers'  => array( 'Authorization' => 'Bearer ' . $key ),
				'url'      => $url,
				'patch'    => array( 'api_bearer_token' => $key ),
				'evidence' => __( 'The key works as a Bearer token.', 'datastream-api-sync' ),
			),
			array(
				'slot'     => 'x-api-key',
				'headers'  => array( 'X-Api-Key' => $key ),
				'url'      => $url,
				'patch'    => array( 'api_headers' => (string) wp_json_encode( array( 'X-Api-Key' => $key ) ) ),
				'evidence' => __( 'The key works in the X-Api-Key header.', 'datastream-api-sync' ),
			),
		);

		foreach ( array( 'api_key', 'apikey', 'key' ) as $param ) {
			$slots[] = array(
				'slot'     => 'query:' . $param,
				'headers'  => array(),
				'url'      => $url . ( false === strpos( $url, '?' ) ? '?' : '&' ) . $param . '=' . rawurlencode( $key ),
				'patch'    => array(
					'api_key_param' => $param,
					'api_key_value' => $key,
				),
				'evidence' => sprintf(
					/* translators: %s: query parameter name */
					__( 'The key works as the "%s" query parameter.', 'datastream-api-sync' ),
					$param
				),
			);
		}

		foreach ( $slots as $slot ) {
			$response = $request( $slot['url'], $slot['headers'] );

			if ( is_wp_error( $response ) ) {
				continue;
			}

			$status = (int) wp_remote_retrieve_response_code( $response );

			if ( $status >= 200 && $status < 300 ) {
				return array(
					'slot'     => $slot['slot'],
					'patch'    => $slot['patch'],
					'evidence' => $slot['evidence'],
				);
			}
		}

		return null;
	}
}
