<?php
namespace MetForm_Pro\Core\Submission_Log;

defined( 'ABSPATH' ) || exit;

/**
 * Data-access layer for the submission failure log.
 *
 * Stores one row per failed submission (timestamp + error message text only —
 * no IP, no submitted field values). Rows are pruned to the newest
 * MAX_PER_FORM entries per form on insert.
 *
 * Direct $wpdb queries are required (no WP API exists for this custom table).
 * The table name comes from $wpdb->prefix + a constant, and every user-supplied
 * value is bound with $wpdb->prepare().
 */
class Database {

	// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL

	private static $TABLE = 'mf_submission_error_logs';

	public static $DB_VERSION_KEY = 'mf_submission_log_db_version';
	public static $DB_VERSION     = '1.0.0';

	/**
	 * Maximum number of log rows kept per form. Older rows are pruned on insert.
	 */
	const MAX_PER_FORM = 100;

	/**
	 * Fully-qualified table name (with the site DB prefix).
	 *
	 * @return string
	 */
	private static function table_name() {
		global $wpdb;
		return $wpdb->prefix . self::$TABLE;
	}

	/**
	 * Create or upgrade the storage table.
	 *
	 * Cheap no-op once the table exists and the stored version matches, so it is
	 * safe to call on every load.
	 */
	public static function update_db() {
		global $wpdb;

		$table = self::table_name();

		$table_exists = $wpdb->get_var(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) )
		) === $table;

		if ( $table_exists && get_option( self::$DB_VERSION_KEY ) === self::$DB_VERSION ) {
			return;
		}

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE {$table} (
    id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
    form_id BIGINT(20) UNSIGNED NOT NULL,
    created_at DATETIME NOT NULL,
    messages TEXT NULL,
    PRIMARY KEY  (id),
    KEY form_created (form_id, created_at)
) {$charset_collate};";

		dbDelta( $sql );

		update_option( self::$DB_VERSION_KEY, self::$DB_VERSION );
	}

	/**
	 * Sanitize and decode a value for storage/display.
	 *
	 * Messages arrive HTML-escaped from the form handler, so entities are decoded
	 * so the log reads naturally (it is re-escaped when rendered on output).
	 *
	 * @param mixed $value
	 * @return string
	 */
	private static function normalize_text( $value ) {
		return sanitize_text_field( wp_specialchars_decode( (string) $value, ENT_QUOTES ) );
	}

	/**
	 * Best-effort classification of a failure from its (fixed) error text, used when
	 * the form handler did not set an explicit failure type.
	 *
	 * @param string $reason
	 * @return string Type key, or '' when it cannot be determined.
	 */
	private static function classify_type( $reason ) {
		$text = strtolower( (string) $reason );

		$patterns = [
			'entry_limit'    => [ 'limit exceeded' ],
			'login_required' => [ 'logged in' ],
			'file_upload'    => [ 'wrong file', 'file size' ],
			'captcha'        => [ 'recaptcha', 'captcha' ],
			'duplicate'      => [ 'already exist' ],
			'form_access'    => [ 'not permitted' ],
			'validation'     => [ 'validation failed', 'criteria is not matched', 'minimum input', 'maximum input' ],
			'config'         => [ 'settings not found' ],
			'security'       => [ 'unauthorized' ],
		];

		foreach ( $patterns as $type => $needles ) {
			foreach ( $needles as $needle ) {
				if ( false !== strpos( $text, $needle ) ) {
					return $type;
				}
			}
		}

		return '';
	}

	/**
	 * Whether a failed submission should be logged.
	 *
	 * We only keep UNEXPECTED failures — ones the site admin/developer needs to see —
	 * and skip the ones where the visitor was already shown a clear, self-explanatory
	 * reason.
	 *
	 * The decision is driven by the failure type the MetForm free handler tags on the
	 * response at its source ($context['type']); this is language-independent and works
	 * with custom messages. Handled types (entry limit, login required, scheduling,
	 * validation, captcha, file upload, duplicate, form access) are skipped. Everything
	 * else — technical/vague failures (nonce → "Unauthorized submission.", integration
	 * or config errors, the generic fallback, or a PHP error) — is logged.
	 *
	 * classify_type() is only a fallback for an older free build that submits an untagged
	 * failure; once tagged, the text of the message is never inspected for this decision.
	 *
	 * @param array        $context  Diagnostic context (carries the source 'type' tag).
	 * @param array|string $messages Error message(s).
	 * @return bool
	 */
	public static function should_log( $context, $messages ) {
		if ( is_array( $messages ) ) {
			$messages = array_filter( array_map( [ __CLASS__, 'normalize_text' ], $messages ) );
			$reason   = implode( '; ', $messages );
		} else {
			$reason = self::normalize_text( $messages );
		}

		$type = ( is_array( $context ) && ! empty( $context['type'] ) )
			? sanitize_key( $context['type'] )
			: self::classify_type( $reason );

		// Failure types that already tell the visitor exactly what happened — not logged.
		$handled = [ 'scheduling', 'validation', 'captcha', 'login_required', 'entry_limit', 'file_upload', 'duplicate', 'form_access' ];

		return ! in_array( $type, $handled, true );
	}

	/**
	 * Human-readable label for a failure type.
	 *
	 * @param string $type
	 * @return string Label, or '' when there is no known label.
	 */
	private static function type_label( $type ) {
		$labels = [
			'scheduling'     => __( 'Form scheduling', 'metform-pro' ),
			'validation'     => __( 'Field validation', 'metform-pro' ),
			'captcha'        => __( 'Captcha verification', 'metform-pro' ),
			'login_required' => __( 'Login required', 'metform-pro' ),
			'entry_limit'    => __( 'Entry limit reached', 'metform-pro' ),
			'file_upload'    => __( 'File upload', 'metform-pro' ),
			'duplicate'      => __( 'Duplicate entry', 'metform-pro' ),
			'form_access'    => __( 'Form access', 'metform-pro' ),
			'security'       => __( 'Security check', 'metform-pro' ),
			'config'         => __( 'Form configuration', 'metform-pro' ),
			'integration'    => __( 'Integration', 'metform-pro' ),
			'email'          => __( 'Email delivery', 'metform-pro' ),
		];

		return isset( $labels[ $type ] ) ? $labels[ $type ] : '';
	}

	/**
	 * Compose the stored log entry: a typed reason headline followed by diagnostic
	 * detail lines (one per line, "\n" separated) explaining why the submission failed.
	 *
	 * @param array|string $messages Error message(s).
	 * @param array        $context  Diagnostic context.
	 * @return string
	 */
	private static function build_message( $messages, $context ) {
		// Specific message(s) reported by the form handler.
		if ( is_array( $messages ) ) {
			$messages = array_filter( array_map( [ __CLASS__, 'normalize_text' ], $messages ) );
			$reason   = implode( '; ', $messages );
		} else {
			$reason = self::normalize_text( $messages );
		}

		// The exact technical cause (PHP error/exception message + file:line), when captured.
		$debug      = ! empty( $context['debug'] ) ? self::normalize_text( $context['debug'] ) : '';
		$unexpected = ! empty( $context['unexpected'] );

		$type = isset( $context['type'] ) ? sanitize_key( $context['type'] ) : '';
		if ( '' === $type ) {
			$type = self::classify_type( $reason );
		}

		if ( $unexpected ) {
			// The visitor only saw a vague/generic message (nonce, "Unauthorized
			// submission.", or the generic fallback), so surface the exact technical cause
			// instead. When no PHP error was captured, point to the server error log.
			if ( '' !== $debug ) {
				$headline = $debug;
				$debug    = ''; // surfaced as the headline — don't repeat it below
			} else {
				$headline = __( 'No error detail was reported by the form handler — check the server error log for the exact cause.', 'metform-pro' );
			}
		} else {
			// Handled failure with a clear, specific message → typed headline.
			$label    = self::type_label( $type );
			$reason   = ( '' === $reason ) ? __( 'Submission failed.', 'metform-pro' ) : $reason;
			$headline = ( '' !== $label ) ? $label . ': ' . $reason : $reason;
		}

		$lines = [ $headline ];

		// Detail lines — context that helps diagnose the cause.
		if ( ! empty( $context['page_url'] ) ) {
			$lines[] = 'Page: ' . esc_url_raw( $context['page_url'] );
		}

		if ( ! empty( $context['user'] ) ) {
			$lines[] = 'User: ' . self::normalize_text( $context['user'] );
		}

		if ( ! empty( $context['fields'] ) && is_array( $context['fields'] ) ) {
			$fields = array_filter( array_map( [ __CLASS__, 'normalize_text' ], $context['fields'] ) );
			if ( ! empty( $fields ) ) {
				$lines[] = 'Fields: ' . implode( ', ', $fields );
			}
		}

		if ( ! empty( $context['has_files'] ) ) {
			$lines[] = 'Attachments: yes';
		}

		// The exact technical cause, when not already used as the headline.
		if ( '' !== $debug ) {
			$lines[] = 'Debug: ' . $debug;
		}

		return implode( "\n", $lines );
	}

	/**
	 * Insert a single failure log row, then prune old rows for the form.
	 *
	 * @param int          $form_id  Form ID.
	 * @param array|string $messages Error message(s) to store.
	 * @param array        $context  Diagnostic context (page_url, user, fields, has_files).
	 * @return int|false Inserted row ID, or false on failure.
	 */
	public static function insert( $form_id, $messages, $context = [] ) {
		global $wpdb;

		$form_id = intval( $form_id );
		if ( $form_id <= 0 ) {
			return false;
		}

		$stored = self::build_message( $messages, is_array( $context ) ? $context : [] );

		$inserted = $wpdb->insert(
			self::table_name(),
			[
				'form_id'    => $form_id,
				'created_at' => current_time( 'mysql' ),
				'messages'   => $stored,
			],
			[ '%d', '%s', '%s' ]
		);

		if ( ! $inserted ) {
			return false;
		}

		self::prune( $form_id );

		return (int) $wpdb->insert_id;
	}

	/**
	 * Keep only the newest MAX_PER_FORM rows for a form.
	 *
	 * @param int $form_id Form ID.
	 */
	private static function prune( $form_id ) {
		global $wpdb;

		$table   = self::table_name();
		$form_id = intval( $form_id );

		$total = (int) $wpdb->get_var(
			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE form_id = %d", $form_id )
		);

		if ( $total <= self::MAX_PER_FORM ) {
			return;
		}

		$remove = $total - self::MAX_PER_FORM;

		$ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT id FROM {$table} WHERE form_id = %d ORDER BY id ASC LIMIT %d",
				$form_id,
				$remove
			)
		);

		if ( empty( $ids ) ) {
			return;
		}

		$ids          = array_map( 'intval', $ids );
		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

		$wpdb->query(
			$wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids )
		);
	}

	/**
	 * Fetch recent failure logs for a form (newest first).
	 *
	 * @param int $form_id Form ID.
	 * @param int $limit   Max rows to return (capped at MAX_PER_FORM).
	 * @return array<int,array{created_at:string,messages:string}>
	 */
	public static function get_logs( $form_id, $limit = 100 ) {
		global $wpdb;

		$form_id = intval( $form_id );
		$limit   = max( 1, min( self::MAX_PER_FORM, intval( $limit ) ) );

		if ( $form_id <= 0 ) {
			return [];
		}

		$table = self::table_name();

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT created_at, messages FROM {$table} WHERE form_id = %d ORDER BY id DESC LIMIT %d",
				$form_id,
				$limit
			),
			ARRAY_A
		);

		return is_array( $rows ) ? $rows : [];
	}

	/**
	 * Delete all logs for a form (used when the form is permanently deleted).
	 *
	 * @param int $form_id Form ID.
	 */
	public static function delete_form_logs( $form_id ) {
		global $wpdb;

		$form_id = intval( $form_id );
		if ( $form_id <= 0 ) {
			return;
		}

		$table = self::table_name();

		$wpdb->query(
			$wpdb->prepare( "DELETE FROM {$table} WHERE form_id = %d", $form_id )
		);
	}

	// phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL
}
