diff --git a/includes/abilities/ads/class-ads-integration.php b/includes/abilities/ads/class-ads-integration.php
new file mode 100644
index 0000000..6f73296
--- /dev/null
+++ b/includes/abilities/ads/class-ads-integration.php
@@ -0,0 +1,241 @@
+ __( 'Ads & Monetization Read', 'emcp-tools' ),
+ 'description' => sprintf(
+ /* translators: %1$s: comma-separated operation names */
+ __( 'Read and inspect ad configurations, WP Quads slots, /ads.txt records, network detection, and ExoClick reporting. Pass the `operation` you want plus its `arguments`. If you omit `operation`, the tool returns its full operations catalog. Read operations: %1$s.', 'emcp-tools' ),
+ implode( ', ', $read_names )
+ ),
+ 'category' => 'emcp-tools',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'operation' => array(
+ 'type' => 'string',
+ 'enum' => $read_names,
+ 'description' => __( 'The read operation to run.', 'emcp-tools' ),
+ ),
+ 'arguments' => array(
+ 'type' => 'object',
+ 'description' => __( 'Arguments passed to the chosen operation (see catalog returned when operation is omitted).', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ 'output_schema' => array( 'type' => 'object' ),
+ 'permission_callback' => array( $this, 'can_read' ),
+ 'execute_callback' => array( $this, 'execute_read' ),
+ 'meta' => array(
+ 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ),
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ emcp_tools_register_ability(
+ 'emcp-tools/ads-write',
+ array(
+ 'label' => __( 'Ads & Monetization Write', 'emcp-tools' ),
+ 'description' => sprintf(
+ /* translators: %1$s: comma-separated operation names */
+ __( 'Create, update, or delete WP Quads ad slots with dual-write sync, edit /ads.txt with IAB validation, flush ad caches, and manage ExoClick zones. Pass the `operation` you want plus its `arguments`. If you omit `operation`, the tool returns its full operations catalog. Write operations: %1$s.', 'emcp-tools' ),
+ implode( ', ', $write_names )
+ ),
+ 'category' => 'emcp-tools',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'operation' => array(
+ 'type' => 'string',
+ 'enum' => $write_names,
+ 'description' => __( 'The write operation to run.', 'emcp-tools' ),
+ ),
+ 'arguments' => array(
+ 'type' => 'object',
+ 'description' => __( 'Arguments passed to the chosen operation (see catalog returned when operation is omitted).', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ 'output_schema' => array( 'type' => 'object' ),
+ 'permission_callback' => array( $this, 'can_write' ),
+ 'execute_callback' => array( $this, 'execute_write' ),
+ 'meta' => array(
+ 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false ),
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Build discovery-catalog response.
+ *
+ * @param string $tool
+ * @param string $description
+ * @param array $spec
+ * @return array
+ */
+ private function catalog( string $tool, string $description, array $spec ): array {
+ $operations = array();
+
+ foreach ( $spec as $name => $entry ) {
+ $schema = (array) ( $entry['schema'] ?? array() );
+ $required = (array) ( $schema['required'] ?? array() );
+ $props = (array) ( $schema['properties'] ?? array() );
+ $args = array();
+
+ foreach ( $props as $arg_name => $prop ) {
+ $args[ $arg_name ] = self::argument_hint( (array) $prop, in_array( $arg_name, $required, true ) );
+ }
+
+ $operations[ $name ] = array(
+ 'description' => (string) ( $entry['description'] ?? $name ),
+ 'arguments' => $args,
+ 'required' => $required,
+ 'schema' => $schema,
+ 'example' => (array) ( $entry['example'] ?? array() ),
+ );
+ }
+
+ return array(
+ 'tool' => $tool,
+ 'description' => $description,
+ 'operations' => $operations,
+ );
+ }
+
+ /**
+ * Short human hint for schema property.
+ *
+ * @param array $prop
+ * @param bool $required
+ * @return string
+ */
+ private static function argument_hint( array $prop, bool $required ): string {
+ $type = isset( $prop['type'] ) ? $prop['type'] : 'mixed';
+ if ( is_array( $type ) ) {
+ $type = implode( '|', $type );
+ }
+ $hint = str_replace( 'integer', 'int', (string) $type );
+
+ if ( ! empty( $prop['enum'] ) && is_array( $prop['enum'] ) ) {
+ $hint .= ' (enum: ' . implode( '|', $prop['enum'] ) . ')';
+ }
+ if ( array_key_exists( 'default', $prop ) ) {
+ $hint .= ' (default: ' . ( is_bool( $prop['default'] ) ? ( $prop['default'] ? 'true' : 'false' ) : (string) $prop['default'] ) . ')';
+ }
+ if ( $required ) {
+ $hint .= ' (required)';
+ }
+
+ return $hint;
+ }
+
+ /**
+ * Permission check for read operations.
+ *
+ * @return bool
+ */
+ public function can_read(): bool {
+ return current_user_can( 'read' ) || current_user_can( 'edit_posts' );
+ }
+
+ /**
+ * Permission check for write operations.
+ *
+ * @return bool
+ */
+ public function can_write(): bool {
+ return current_user_can( 'manage_options' );
+ }
+
+ /**
+ * Execute read operation.
+ *
+ * @param array $args
+ * @return array|\WP_Error
+ */
+ public function execute_read( array $args ) {
+ $op = trim( (string) ( $args['operation'] ?? '' ) );
+ $in = (array) ( $args['arguments'] ?? array() );
+
+ if ( '' === $op ) {
+ return $this->catalog(
+ 'emcp-tools/ads-read',
+ __( 'Read and inspect ad configurations, WP Quads slots, /ads.txt records, and ExoClick reporting.', 'emcp-tools' ),
+ EMCP_Tools_Ads_Read_Operations::op_schema()
+ );
+ }
+
+ return EMCP_Tools_Ads_Read_Operations::execute( $op, $in );
+ }
+
+ /**
+ * Execute write operation.
+ *
+ * @param array $args
+ * @return array|\WP_Error
+ */
+ public function execute_write( array $args ) {
+ $op = trim( (string) ( $args['operation'] ?? '' ) );
+ $in = (array) ( $args['arguments'] ?? array() );
+
+ if ( '' === $op ) {
+ return $this->catalog(
+ 'emcp-tools/ads-write',
+ __( 'Create, update, or delete WP Quads ad slots with dual-write sync, edit /ads.txt, flush ad caches, and manage ExoClick zones.', 'emcp-tools' ),
+ EMCP_Tools_Ads_Write_Operations::op_schema()
+ );
+ }
+
+ return EMCP_Tools_Ads_Write_Operations::execute( $op, $in );
+ }
+}
diff --git a/includes/abilities/ads/class-ads-read-operations.php b/includes/abilities/ads/class-ads-read-operations.php
new file mode 100644
index 0000000..71506d1
--- /dev/null
+++ b/includes/abilities/ads/class-ads-read-operations.php
@@ -0,0 +1,609 @@
+
+ */
+ public static function op_schema(): array {
+ return array(
+ 'list-ads' => array(
+ 'description' => __( 'List all registered ad units from WP Quads and active ad locations with detected networks and dimensions.', 'emcp-tools' ),
+ 'example' => array( 'status' => 'all' ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => array( 'all', 'active', 'inactive' ),
+ 'default' => 'all',
+ 'description' => __( 'Filter ads by status.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'get-ad' => array(
+ 'description' => __( 'Get complete configuration, dimensions, code, and placement for a specific ad slot by Post ID or Quads key (e.g. ad1).', 'emcp-tools' ),
+ 'example' => array( 'id' => 'ad1' ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'required' => array( 'id' ),
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'string',
+ 'description' => __( 'Post ID (e.g. "13595") or WP Quads slot key (e.g. "ad1", "ad2").', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'get-ads-txt' => array(
+ 'description' => __( 'Read and parse /ads.txt records, validate IAB compliance, and check authorized sellers.', 'emcp-tools' ),
+ 'example' => array(),
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'validate_syntax' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ 'description' => __( 'Whether to validate IAB formatting for each record.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'audit-monetization' => array(
+ 'description' => __( 'Run comprehensive diagnostic audit on site ads: check ads.txt health, detect duplicate pop scripts, inspect mobile responsive styling, and verify affiliate cache status.', 'emcp-tools' ),
+ 'example' => array(),
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'check_remote' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ 'description' => __( 'Perform live HTTP check on /ads.txt endpoint.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'exoclick-list-zones' => array(
+ 'description' => __( 'Query ExoClick REST API to list all ad zones for a site ID, showing dimensions, refresh rates, and active status.', 'emcp-tools' ),
+ 'example' => array( 'idsite' => 1111220 ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'api_token' => array(
+ 'type' => 'string',
+ 'description' => __( 'ExoClick API token (optional if configured in settings).', 'emcp-tools' ),
+ ),
+ 'idsite' => array(
+ 'type' => 'integer',
+ 'description' => __( 'ExoClick Site ID (defaults to saved site ID).', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'exoclick-get-stats' => array(
+ 'description' => __( 'Query ExoClick reporting API for impression, click, CTR, eCPM, and revenue metrics.', 'emcp-tools' ),
+ 'example' => array( 'date_from' => '2026-09-01', 'date_to' => '2026-09-05' ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'api_token' => array(
+ 'type' => 'string',
+ 'description' => __( 'ExoClick API token (optional if configured in settings).', 'emcp-tools' ),
+ ),
+ 'date_from' => array(
+ 'type' => 'string',
+ 'description' => __( 'Start date in YYYY-MM-DD format.', 'emcp-tools' ),
+ ),
+ 'date_to' => array(
+ 'type' => 'string',
+ 'description' => __( 'End date in YYYY-MM-DD format.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Detect ad network provider from raw HTML/JS code.
+ *
+ * @param string $code Raw ad markup.
+ * @return array{network:string,zone_id:?string,dimensions:?string}
+ */
+ public static function parse_ad_code( string $code ): array {
+ $network = 'Custom';
+ $zone_id = null;
+ $dimensions = null;
+
+ if ( preg_match( '/data-zoneid=["\'](\d+)["\']/i', $code, $matches ) || preg_match( '/idzone["\']?\s*[:=]\s*(\d+)/i', $code, $matches ) ) {
+ $network = 'ExoClick';
+ $zone_id = $matches[1];
+ } elseif ( preg_match( '/(?:adzone|adzone_id|jads\.js|juicyads\.com)[^\d]*(\d{5,8})/i', $code, $matches ) ) {
+ $network = 'JuicyAds';
+ $zone_id = $matches[1];
+ } elseif ( stripos( $code, 'trafficstars' ) !== false || preg_match( '/trafficstars\.com[^\d]*(\d+)/i', $code, $matches ) ) {
+ $network = 'TrafficStars';
+ $zone_id = $matches[1] ?? null;
+ } elseif ( stripos( $code, 'chaturbate' ) !== false || stripos( $code, 'cbxyz' ) !== false ) {
+ $network = 'Chaturbate';
+ } elseif ( stripos( $code, 'googlesyndication' ) !== false || stripos( $code, 'adsbygoogle' ) !== false ) {
+ $network = 'Google AdSense';
+ }
+
+ if ( preg_match( '/(?:data-width|width)=["\']?(\d+)["\']?\s+(?:data-height|height)=["\']?(\d+)["\']?/i', $code, $dim_matches ) ) {
+ $dimensions = "{$dim_matches[1]}x{$dim_matches[2]}";
+ } elseif ( preg_match( '/(\d{2,4})x(\d{2,4})/', $code, $dim_matches ) ) {
+ $dimensions = "{$dim_matches[1]}x{$dim_matches[2]}";
+ }
+
+ return array(
+ 'network' => $network,
+ 'zone_id' => $zone_id,
+ 'dimensions' => $dimensions,
+ );
+ }
+
+ /**
+ * Execute read operations.
+ *
+ * @param string $op Operation name.
+ * @param array $args Operation arguments.
+ * @return array
+ */
+ public static function execute( string $op, array $args = array() ): array {
+ switch ( $op ) {
+ case 'list-ads':
+ return self::list_ads( $args );
+ case 'get-ad':
+ return self::get_ad( $args );
+ case 'get-ads-txt':
+ return self::get_ads_txt( $args );
+ case 'audit-monetization':
+ return self::audit_monetization( $args );
+ case 'exoclick-list-zones':
+ return self::exoclick_list_zones( $args );
+ case 'exoclick-get-stats':
+ return self::exoclick_get_stats( $args );
+ default:
+ return array(
+ 'success' => false,
+ 'error' => sprintf( 'Unknown read operation: %s', $op ),
+ );
+ }
+ }
+
+ /**
+ * List all configured ad slots.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function list_ads( array $args = array() ): array {
+ $filter_status = $args['status'] ?? 'all';
+ $ads = array();
+
+ $settings = get_option( 'quads_settings' );
+ $slots = is_array( $settings ) && isset( $settings['ads'] ) ? (array) $settings['ads'] : array();
+
+ $posts = function_exists( 'get_posts' ) ? get_posts(
+ array(
+ 'post_type' => 'quads-ads',
+ 'post_status' => 'any',
+ 'posts_per_page' => 100,
+ )
+ ) : array();
+
+ $post_map = array();
+ foreach ( $posts as $p ) {
+ $old_id = get_post_meta( $p->ID, 'quads_ad_old_id', true ) ?: '';
+ if ( ! empty( $old_id ) ) {
+ $post_map[ $old_id ] = $p;
+ }
+ $post_map[ 'id_' . $p->ID ] = $p;
+ }
+
+ foreach ( $slots as $slot_key => $slot_data ) {
+ if ( ! is_array( $slot_data ) ) {
+ continue;
+ }
+
+ $post = $post_map[ $slot_key ] ?? null;
+ $post_id = $post ? (int) $post->ID : 0;
+ $code = $slot_data['code'] ?? ( $post_id ? (string) get_post_meta( $post_id, 'code', true ) : '' );
+ $parsed = self::parse_ad_code( $code );
+ $title = $slot_data['label'] ?? ( $post ? $post->post_title : $slot_key );
+ $position = $slot_data['position'] ?? ( $post_id ? (string) get_post_meta( $post_id, 'position', true ) : 'unknown' );
+ $is_active = ! empty( $code );
+
+ if ( 'active' === $filter_status && ! $is_active ) {
+ continue;
+ }
+ if ( 'inactive' === $filter_status && $is_active ) {
+ continue;
+ }
+
+ $ads[] = array(
+ 'slot_key' => $slot_key,
+ 'post_id' => $post_id,
+ 'title' => $title,
+ 'position' => $position,
+ 'detected_network' => $parsed['network'],
+ 'zone_id' => $parsed['zone_id'],
+ 'dimensions' => $parsed['dimensions'] ?: ( $slot_data['dimensions'] ?? null ),
+ 'is_active' => $is_active,
+ 'code_preview' => mb_substr( trim( $code ), 0, 150 ) . ( mb_strlen( $code ) > 150 ? '...' : '' ),
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'total_ads' => count( $ads ),
+ 'ads' => $ads,
+ 'quads_mode' => get_option( 'quads-mode', 'new' ),
+ );
+ }
+
+ /**
+ * Get details for a specific ad slot.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function get_ad( array $args ): array {
+ $id = trim( (string) ( $args['id'] ?? '' ) );
+ if ( '' === $id ) {
+ return array( 'success' => false, 'error' => 'Argument "id" is required.' );
+ }
+
+ $settings = get_option( 'quads_settings', array() );
+ $slot_data = null;
+ $post_id = 0;
+ $slot_key = null;
+
+ if ( isset( $settings['ads'][ $id ] ) && is_array( $settings['ads'][ $id ] ) ) {
+ $slot_data = $settings['ads'][ $id ];
+ $slot_key = $id;
+ if ( function_exists( 'get_posts' ) ) {
+ $found = get_posts(
+ array(
+ 'post_type' => 'quads-ads',
+ 'meta_key' => 'quads_ad_old_id',
+ 'meta_value' => $slot_key,
+ 'numberposts' => 1,
+ )
+ );
+ if ( ! empty( $found ) ) {
+ $post_id = $found[0]->ID;
+ }
+ }
+ } elseif ( is_numeric( $id ) && function_exists( 'get_post' ) ) {
+ $p = get_post( (int) $id );
+ if ( $p && 'quads-ads' === $p->post_type ) {
+ $post_id = $p->ID;
+ $old_id = (string) get_post_meta( $post_id, 'quads_ad_old_id', true );
+ if ( ! empty( $old_id ) && isset( $settings['ads'][ $old_id ] ) ) {
+ $slot_data = $settings['ads'][ $old_id ];
+ $slot_key = $old_id;
+ }
+ }
+ }
+
+ if ( ! $slot_data && ! $post_id ) {
+ return array( 'success' => false, 'error' => sprintf( 'Ad slot "%s" not found.', $id ) );
+ }
+
+ $code = $slot_data['code'] ?? ( $post_id ? (string) get_post_meta( $post_id, 'code', true ) : '' );
+
+ return array(
+ 'success' => true,
+ 'slot_key' => $slot_key ?? $id,
+ 'post_id' => $post_id,
+ 'title' => $slot_data['label'] ?? ( $post_id ? get_the_title( $post_id ) : '' ),
+ 'code' => $code,
+ 'parsed' => self::parse_ad_code( $code ),
+ 'settings' => $slot_data ?: array(),
+ 'postmeta' => $post_id && function_exists( 'get_post_meta' ) ? get_post_meta( $post_id ) : array(),
+ );
+ }
+
+ /**
+ * Parse and validate ads.txt.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function get_ads_txt( array $args = array() ): array {
+ $raw = '';
+
+ $option = get_option( 'emcp_tools_ads_txt' );
+ if ( is_string( $option ) && ! empty( $option ) ) {
+ $raw = $option;
+ }
+
+ if ( empty( $raw ) && defined( 'ABSPATH' ) && file_exists( ABSPATH . 'ads.txt' ) && is_readable( ABSPATH . 'ads.txt' ) ) {
+ $raw = (string) file_get_contents( ABSPATH . 'ads.txt' );
+ }
+
+ if ( empty( $raw ) && function_exists( 'wp_remote_get' ) ) {
+ $resp = wp_remote_get( home_url( '/ads.txt' ), array( 'timeout' => 4 ) );
+ if ( ! is_wp_error( $resp ) && 200 === wp_remote_retrieve_response_code( $resp ) ) {
+ $raw = (string) wp_remote_retrieve_body( $resp );
+ }
+ }
+
+ $records = array();
+ $lines = explode( "\n", str_replace( "\r", '', $raw ) );
+ $warnings = array();
+
+ foreach ( $lines as $idx => $line ) {
+ $trimmed = trim( $line );
+ if ( '' === $trimmed || 0 === strpos( $trimmed, '#' ) ) {
+ continue;
+ }
+
+ $parts = array_map( 'trim', explode( ',', $trimmed ) );
+ if ( count( $parts ) < 3 ) {
+ $warnings[] = sprintf( 'Line %d has invalid format (<3 comma-separated fields): "%s"', $idx + 1, $trimmed );
+ continue;
+ }
+
+ $domain = strtolower( $parts[0] );
+ $pub_id = $parts[1];
+ $relation = strtoupper( $parts[2] );
+ $cert_id = $parts[3] ?? null;
+
+ $valid_relation = in_array( $relation, array( 'DIRECT', 'RESELLER' ), true );
+ if ( ! $valid_relation ) {
+ $warnings[] = sprintf( 'Line %d has invalid relationship "%s" (must be DIRECT or RESELLER)', $idx + 1, $relation );
+ }
+
+ $records[] = array(
+ 'line_number' => $idx + 1,
+ 'domain' => $domain,
+ 'publisher_id' => $pub_id,
+ 'relationship' => $relation,
+ 'certification_authority_id' => $cert_id,
+ 'is_valid' => $valid_relation && ! empty( $domain ) && ! empty( $pub_id ),
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'raw' => $raw,
+ 'total_records' => count( $records ),
+ 'records' => $records,
+ 'warnings' => $warnings,
+ 'source' => ! empty( $option ) ? 'option' : ( defined( 'ABSPATH' ) && file_exists( ABSPATH . 'ads.txt' ) ? 'file' : 'live_url' ),
+ );
+ }
+
+ /**
+ * Run comprehensive monetization audit.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function audit_monetization( array $args = array() ): array {
+ $issues = array();
+ $recommendations = array();
+
+ $ads_txt = self::get_ads_txt();
+ if ( 0 === $ads_txt['total_records'] ) {
+ $issues[] = 'No ads.txt records found. Major SSPs and programmatic buyers will refuse to bid on site inventory.';
+ $recommendations[] = 'Configure /ads.txt with authorized publisher lines for active networks (ExoClick, TrafficStars, JuicyAds).';
+ } elseif ( ! empty( $ads_txt['warnings'] ) ) {
+ $issues[] = sprintf( 'Found %d warnings in /ads.txt syntax.', count( $ads_txt['warnings'] ) );
+ }
+
+ $ads_list = self::list_ads();
+ $active_ads = array_filter( $ads_list['ads'], fn( $ad ) => ! empty( $ad['is_active'] ) );
+
+ if ( 0 === count( $active_ads ) ) {
+ $issues[] = 'No active ad units found in WP Quads.';
+ $recommendations[] = 'Create responsive banner placements (Above Comic 728x90, Under Comic 300x250, Sidebar 160x600).';
+ }
+
+ $juicy_count = 0;
+ $exo_count = 0;
+ foreach ( $active_ads as $ad ) {
+ if ( 'JuicyAds' === $ad['detected_network'] ) {
+ $juicy_count++;
+ } elseif ( 'ExoClick' === $ad['detected_network'] ) {
+ $exo_count++;
+ }
+ }
+
+ if ( $juicy_count > 0 && $exo_count > 0 ) {
+ $issues[] = sprintf( 'Site is running a hybrid setup (%d ExoClick, %d JuicyAds). Consider standardizing on ExoClick to avoid remnant backfill and multiple external JS dependencies.', $exo_count, $juicy_count );
+ }
+
+ return array(
+ 'success' => true,
+ 'status' => empty( $issues ) ? 'healthy' : 'attention_required',
+ 'active_ad_units' => count( $active_ads ),
+ 'ads_txt_records' => $ads_txt['total_records'],
+ 'issues' => $issues,
+ 'recommendations' => $recommendations,
+ );
+ }
+
+ /**
+ * Helper: Resolve ExoClick API token.
+ *
+ * @param array $args
+ * @return string|null
+ */
+ public static function get_exoclick_token( array $args = array() ): ?string {
+ if ( ! empty( $args['api_token'] ) && is_string( $args['api_token'] ) ) {
+ return trim( $args['api_token'] );
+ }
+ if ( defined( 'EXOCLICK_API_TOKEN' ) && ! empty( EXOCLICK_API_TOKEN ) ) {
+ return EXOCLICK_API_TOKEN;
+ }
+ $opt = get_option( 'emcp_tools_exoclick_api_token' );
+ return ! empty( $opt ) && is_string( $opt ) ? $opt : null;
+ }
+
+ /**
+ * Query ExoClick API to list zones.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function exoclick_list_zones( array $args = array() ): array {
+ $token = self::get_exoclick_token( $args );
+ if ( ! $token ) {
+ return array(
+ 'success' => false,
+ 'error' => 'ExoClick API token is required. Pass "api_token" or configure "emcp_tools_exoclick_api_token".',
+ );
+ }
+
+ $bearer = self::authenticate_exoclick( $token );
+ if ( is_wp_error( $bearer ) ) {
+ return array( 'success' => false, 'error' => $bearer->get_error_message() );
+ }
+
+ $site_id = $args['idsite'] ?? get_option( 'emcp_tools_exoclick_site_id', 1111220 );
+ $url = 'https://api.exoclick.com/v2/zones' . ( $site_id ? '?idsite=' . absint( $site_id ) : '' );
+
+ $res = wp_remote_get(
+ $url,
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $bearer,
+ 'User-Agent' => 'EMCP-Tools/WordPress',
+ ),
+ 'timeout' => 10,
+ )
+ );
+
+ if ( is_wp_error( $res ) ) {
+ return array( 'success' => false, 'error' => $res->get_error_message() );
+ }
+
+ $data = json_decode( wp_remote_retrieve_body( $res ), true );
+ if ( ! is_array( $data ) ) {
+ return array( 'success' => false, 'error' => 'Invalid JSON from ExoClick API.' );
+ }
+
+ return array(
+ 'success' => true,
+ 'site_id' => $site_id,
+ 'zones' => $data['result'] ?? $data,
+ );
+ }
+
+ /**
+ * Query ExoClick API for reporting statistics.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function exoclick_get_stats( array $args = array() ): array {
+ $token = self::get_exoclick_token( $args );
+ if ( ! $token ) {
+ return array(
+ 'success' => false,
+ 'error' => 'ExoClick API token is required.',
+ );
+ }
+
+ $bearer = self::authenticate_exoclick( $token );
+ if ( is_wp_error( $bearer ) ) {
+ return array( 'success' => false, 'error' => $bearer->get_error_message() );
+ }
+
+ $date_from = $args['date_from'] ?? gmdate( 'Y-m-d', strtotime( '-7 days' ) );
+ $date_to = $args['date_to'] ?? gmdate( 'Y-m-d' );
+ $url = sprintf( 'https://api.exoclick.com/v2/statistics/p/zone?date_from=%s&date_to=%s', urlencode( $date_from ), urlencode( $date_to ) );
+
+ $res = wp_remote_get(
+ $url,
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $bearer,
+ 'User-Agent' => 'EMCP-Tools/WordPress',
+ ),
+ 'timeout' => 15,
+ )
+ );
+
+ if ( is_wp_error( $res ) ) {
+ return array( 'success' => false, 'error' => $res->get_error_message() );
+ }
+
+ $data = json_decode( wp_remote_retrieve_body( $res ), true );
+ return array(
+ 'success' => true,
+ 'date_from' => $date_from,
+ 'date_to' => $date_to,
+ 'stats' => $data['result'] ?? $data,
+ );
+ }
+
+ /**
+ * Authenticate API token and return Bearer session token with transient caching.
+ *
+ * @param string $api_token
+ * @return string|\WP_Error
+ */
+ public static function authenticate_exoclick( string $api_token ) {
+ $cache_key = 'emcp_exoclick_bearer_' . md5( $api_token );
+ $cached = function_exists( 'get_transient' ) ? get_transient( $cache_key ) : false;
+ if ( ! empty( $cached ) && is_string( $cached ) ) {
+ return $cached;
+ }
+
+ $url = 'https://api.exoclick.com/v2/login';
+ $body = wp_json_encode( array( 'api_token' => $api_token ) );
+
+ $res = wp_remote_post(
+ $url,
+ array(
+ 'headers' => array(
+ 'Content-Type' => 'application/json',
+ 'User-Agent' => 'EMCP-Tools/WordPress',
+ ),
+ 'body' => $body,
+ 'timeout' => 10,
+ )
+ );
+
+ if ( is_wp_error( $res ) ) {
+ return $res;
+ }
+
+ $data = json_decode( wp_remote_retrieve_body( $res ), true );
+ if ( empty( $data['token'] ) ) {
+ return new \WP_Error( 'auth_failed', $data['message'] ?? 'ExoClick API authentication failed.' );
+ }
+
+ $bearer_token = $data['token'];
+ $ttl = isset( $data['expires_in'] ) ? max( 60, (int) $data['expires_in'] - 120 ) : 3600;
+ if ( function_exists( 'set_transient' ) ) {
+ set_transient( $cache_key, $bearer_token, $ttl );
+ }
+
+ return $bearer_token;
+ }
+}
diff --git a/includes/abilities/ads/class-ads-write-operations.php b/includes/abilities/ads/class-ads-write-operations.php
new file mode 100644
index 0000000..51dc54f
--- /dev/null
+++ b/includes/abilities/ads/class-ads-write-operations.php
@@ -0,0 +1,816 @@
+
+ */
+ public static function op_schema(): array {
+ return array(
+ 'update-ad' => array(
+ 'description' => __( 'Update an existing ad unit with dual-write synchronization across WP Quads postmeta and quads_settings.', 'emcp-tools' ),
+ 'example' => array(
+ 'id' => 'ad1',
+ 'code' => '',
+ ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'required' => array( 'id' ),
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'string',
+ 'description' => __( 'Slot key (e.g. "ad1") or post ID (e.g. "13595").', 'emcp-tools' ),
+ ),
+ 'code' => array(
+ 'type' => 'string',
+ 'description' => __( 'New HTML/JS tag markup for this ad unit.', 'emcp-tools' ),
+ ),
+ 'title' => array(
+ 'type' => 'string',
+ 'description' => __( 'Optional new label/title for the ad unit.', 'emcp-tools' ),
+ ),
+ 'position' => array(
+ 'type' => 'string',
+ 'description' => __( 'Placement position (e.g. "custom", "before_post", "after_post").', 'emcp-tools' ),
+ ),
+ 'dimensions' => array(
+ 'type' => 'string',
+ 'description' => __( 'Ad dimensions (e.g. "728x90", "300x250").', 'emcp-tools' ),
+ ),
+ 'settings' => array(
+ 'type' => 'object',
+ 'description' => __( 'Additional WP Quads settings parameters.', 'emcp-tools' ),
+ ),
+ 'purge_cache' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ 'description' => __( 'Whether to purge page cache and object cache after updating.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'create-ad' => array(
+ 'description' => __( 'Create a new ad unit with dual-write to WP Quads posts and serialized options.', 'emcp-tools' ),
+ 'example' => array(
+ 'title' => 'Footer Ad',
+ 'code' => '',
+ 'position' => 'custom',
+ ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'required' => array( 'title', 'code' ),
+ 'properties' => array(
+ 'title' => array(
+ 'type' => 'string',
+ 'description' => __( 'Name / label for the ad slot.', 'emcp-tools' ),
+ ),
+ 'code' => array(
+ 'type' => 'string',
+ 'description' => __( 'HTML/JS tag markup for the ad unit.', 'emcp-tools' ),
+ ),
+ 'slot_key' => array(
+ 'type' => 'string',
+ 'description' => __( 'WP Quads slot key (e.g. "ad12"). If omitted, auto-generated based on existing keys.', 'emcp-tools' ),
+ ),
+ 'position' => array(
+ 'type' => 'string',
+ 'default' => 'custom',
+ 'description' => __( 'Placement position.', 'emcp-tools' ),
+ ),
+ 'dimensions' => array(
+ 'type' => 'string',
+ 'description' => __( 'Dimensions string (e.g. "728x90").', 'emcp-tools' ),
+ ),
+ 'settings' => array(
+ 'type' => 'object',
+ 'description' => __( 'Additional WP Quads options dict.', 'emcp-tools' ),
+ ),
+ 'purge_cache' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ 'description' => __( 'Whether to flush cache after creation.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'delete-ad' => array(
+ 'description' => __( 'Delete or deactivate an ad unit from WP Quads and quads_settings with safety confirmation guard.', 'emcp-tools' ),
+ 'example' => array(
+ 'id' => 'ad12',
+ 'confirm' => true,
+ ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'required' => array( 'id', 'confirm' ),
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'string',
+ 'description' => __( 'Slot key or post ID to delete.', 'emcp-tools' ),
+ ),
+ 'confirm' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Safety guard: must explicitly be true to execute deletion.', 'emcp-tools' ),
+ ),
+ 'purge_cache' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ 'description' => __( 'Whether to flush cache after deletion.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'set-ads-txt' => array(
+ 'description' => __( 'Set or append records to /ads.txt with IAB syntax validation and physical/option dual-write.', 'emcp-tools' ),
+ 'example' => array(
+ 'append_records' => array( 'exoclick.com, 1111220, DIRECT, f6e6255c27770857' ),
+ ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'content' => array(
+ 'type' => 'string',
+ 'description' => __( 'Full text content to replace /ads.txt completely.', 'emcp-tools' ),
+ ),
+ 'append_records' => array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ 'description' => __( 'List of individual record lines to append to existing /ads.txt.', 'emcp-tools' ),
+ ),
+ 'backup' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ 'description' => __( 'Whether to save previous content to backup option before changing.', 'emcp-tools' ),
+ ),
+ 'purge_cache' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ 'description' => __( 'Whether to flush page cache after updating ads.txt.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'purge-ad-cache' => array(
+ 'description' => __( 'Flush LiteSpeed page cache, WP object cache, and temporary ExoClick API transients.', 'emcp-tools' ),
+ 'example' => array( 'all' => true ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'all' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ 'description' => __( 'Flush all ad and site caches.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'exoclick-create-zone' => array(
+ 'description' => __( 'Create a new ad zone on ExoClick via API and optionally install tag directly into WP Quads slot.', 'emcp-tools' ),
+ 'example' => array(
+ 'name' => 'Header Banner',
+ 'dimensions' => '728x90',
+ 'idsite' => 1111220,
+ ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'required' => array( 'name' ),
+ 'properties' => array(
+ 'name' => array(
+ 'type' => 'string',
+ 'description' => __( 'Zone name (e.g. "Above Comic - 728x90").', 'emcp-tools' ),
+ ),
+ 'dimensions' => array(
+ 'type' => 'string',
+ 'description' => __( 'Dimensions (e.g. "728x90", "300x250", "160x600").', 'emcp-tools' ),
+ ),
+ 'idsub_type_format' => array(
+ 'type' => 'integer',
+ 'description' => __( 'ExoClick sub type format ID (e.g. 1 for banner, 7 for popunder).', 'emcp-tools' ),
+ ),
+ 'idsite' => array(
+ 'type' => 'integer',
+ 'description' => __( 'ExoClick Site ID (defaults to saved site ID).', 'emcp-tools' ),
+ ),
+ 'api_token' => array(
+ 'type' => 'string',
+ 'description' => __( 'ExoClick API token (optional if configured).', 'emcp-tools' ),
+ ),
+ 'install_to_slot' => array(
+ 'type' => 'string',
+ 'description' => __( 'WP Quads slot key (e.g. "ad1") or "create" to automatically install generated tag.', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ 'exoclick-verify-site' => array(
+ 'description' => __( 'Verify website ownership with ExoClick by deploying verification token and triggering API confirmation.', 'emcp-tools' ),
+ 'example' => array( 'idsite' => 1111220 ),
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'idsite' => array(
+ 'type' => 'integer',
+ 'description' => __( 'ExoClick Site ID.', 'emcp-tools' ),
+ ),
+ 'api_token' => array(
+ 'type' => 'string',
+ 'description' => __( 'ExoClick API token (optional if configured).', 'emcp-tools' ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Execute write operations.
+ *
+ * @param string $op Operation name.
+ * @param array $args Operation arguments.
+ * @return array
+ */
+ public static function execute( string $op, array $args = array() ): array {
+ switch ( $op ) {
+ case 'update-ad':
+ return self::update_ad( $args );
+ case 'create-ad':
+ return self::create_ad( $args );
+ case 'delete-ad':
+ return self::delete_ad( $args );
+ case 'set-ads-txt':
+ return self::set_ads_txt( $args );
+ case 'purge-ad-cache':
+ return self::purge_ad_cache( $args );
+ case 'exoclick-create-zone':
+ return self::exoclick_create_zone( $args );
+ case 'exoclick-verify-site':
+ return self::exoclick_verify_site( $args );
+ default:
+ return array(
+ 'success' => false,
+ 'error' => sprintf( 'Unknown write operation: %s', $op ),
+ );
+ }
+ }
+
+ /**
+ * Update an ad slot with dual-write sync.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function update_ad( array $args ): array {
+ $id = trim( (string) ( $args['id'] ?? '' ) );
+ if ( '' === $id ) {
+ return array( 'success' => false, 'error' => 'Argument "id" is required.' );
+ }
+
+ $settings = get_option( 'quads_settings', array() );
+ if ( ! is_array( $settings ) ) {
+ $settings = array();
+ }
+ if ( ! isset( $settings['ads'] ) || ! is_array( $settings['ads'] ) ) {
+ $settings['ads'] = array();
+ }
+
+ $post_id = 0;
+ $slot_key = null;
+ $post = null;
+
+ if ( isset( $settings['ads'][ $id ] ) ) {
+ $slot_key = $id;
+ if ( function_exists( 'get_posts' ) ) {
+ $found = get_posts(
+ array(
+ 'post_type' => 'quads-ads',
+ 'meta_key' => 'quads_ad_old_id',
+ 'meta_value' => $slot_key,
+ 'numberposts' => 1,
+ )
+ );
+ if ( ! empty( $found ) ) {
+ $post = $found[0];
+ $post_id = $post->ID;
+ }
+ }
+ } elseif ( is_numeric( $id ) && function_exists( 'get_post' ) ) {
+ $p = get_post( (int) $id );
+ if ( $p && 'quads-ads' === $p->post_type ) {
+ $post = $p;
+ $post_id = $p->ID;
+ $old_id = (string) get_post_meta( $post_id, 'quads_ad_old_id', true );
+ $slot_key = ! empty( $old_id ) ? $old_id : 'ad_' . $post_id;
+ }
+ }
+
+ if ( ! $slot_key && ! $post_id ) {
+ return array( 'success' => false, 'error' => sprintf( 'Ad slot "%s" not found to update.', $id ) );
+ }
+
+ $current_slot_data = $settings['ads'][ $slot_key ] ?? array();
+ if ( ! is_array( $current_slot_data ) ) {
+ $current_slot_data = array();
+ }
+
+ $new_code = isset( $args['code'] ) ? (string) $args['code'] : ( $current_slot_data['code'] ?? '' );
+ $new_title = isset( $args['title'] ) ? (string) $args['title'] : ( $current_slot_data['label'] ?? ( $post ? $post->post_title : $slot_key ) );
+ $new_position = isset( $args['position'] ) ? (string) $args['position'] : ( $current_slot_data['position'] ?? 'custom' );
+ $new_dimensions = isset( $args['dimensions'] ) ? (string) $args['dimensions'] : ( $current_slot_data['dimensions'] ?? '' );
+
+ // 1. Dual-write to quads_settings
+ $current_slot_data['code'] = $new_code;
+ $current_slot_data['label'] = $new_title;
+ $current_slot_data['position'] = $new_position;
+ if ( ! empty( $new_dimensions ) ) {
+ $current_slot_data['dimensions'] = $new_dimensions;
+ }
+ if ( isset( $args['settings'] ) && is_array( $args['settings'] ) ) {
+ $current_slot_data = array_merge( $current_slot_data, $args['settings'] );
+ }
+ $settings['ads'][ $slot_key ] = $current_slot_data;
+ update_option( 'quads_settings', $settings );
+
+ // 2. Dual-write to quads-ads post if exists
+ if ( $post_id && function_exists( 'update_post_meta' ) ) {
+ update_post_meta( $post_id, 'code', $new_code );
+ update_post_meta( $post_id, 'position', $new_position );
+ if ( ! empty( $new_dimensions ) ) {
+ update_post_meta( $post_id, 'dimensions', $new_dimensions );
+ }
+ if ( isset( $args['title'] ) && function_exists( 'wp_update_post' ) ) {
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_title' => $new_title,
+ )
+ );
+ }
+ }
+
+ // 3. Cache flush
+ if ( $args['purge_cache'] ?? true ) {
+ self::purge_ad_cache();
+ }
+
+ return array(
+ 'success' => true,
+ 'slot_key' => $slot_key,
+ 'post_id' => $post_id,
+ 'title' => $new_title,
+ 'detected_network' => EMCP_Tools_Ads_Read_Operations::parse_ad_code( $new_code ),
+ 'message' => sprintf( 'Ad slot "%s" updated successfully.', $slot_key ),
+ );
+ }
+
+ /**
+ * Create a new ad slot with dual-write sync.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function create_ad( array $args ): array {
+ $title = trim( (string) ( $args['title'] ?? '' ) );
+ $code = (string) ( $args['code'] ?? '' );
+
+ if ( '' === $title || '' === $code ) {
+ return array( 'success' => false, 'error' => 'Arguments "title" and "code" are required.' );
+ }
+
+ $settings = get_option( 'quads_settings', array() );
+ if ( ! is_array( $settings ) ) {
+ $settings = array();
+ }
+ if ( ! isset( $settings['ads'] ) || ! is_array( $settings['ads'] ) ) {
+ $settings['ads'] = array();
+ }
+
+ // Generate slot key if not provided
+ $slot_key = trim( (string) ( $args['slot_key'] ?? '' ) );
+ if ( '' === $slot_key ) {
+ $max_num = 0;
+ foreach ( array_keys( $settings['ads'] ) as $k ) {
+ if ( preg_match( '/^ad(\d+)$/i', $k, $m ) ) {
+ $max_num = max( $max_num, (int) $m[1] );
+ }
+ }
+ $slot_key = 'ad' . ( $max_num + 1 );
+ }
+
+ $position = $args['position'] ?? 'custom';
+ $dimensions = $args['dimensions'] ?? '';
+ if ( empty( $dimensions ) ) {
+ $parsed = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $code );
+ $dimensions = $parsed['dimensions'] ?? '';
+ }
+
+ // 1. Create quads-ads post if post type exists
+ $post_id = 0;
+ if ( function_exists( 'wp_insert_post' ) ) {
+ $post_id = wp_insert_post(
+ array(
+ 'post_title' => $title,
+ 'post_type' => 'quads-ads',
+ 'post_status' => 'publish',
+ )
+ );
+ if ( $post_id && ! is_wp_error( $post_id ) ) {
+ update_post_meta( $post_id, 'quads_ad_old_id', $slot_key );
+ update_post_meta( $post_id, 'code', $code );
+ update_post_meta( $post_id, 'position', $position );
+ if ( ! empty( $dimensions ) ) {
+ update_post_meta( $post_id, 'dimensions', $dimensions );
+ }
+ } else {
+ $post_id = 0;
+ }
+ }
+
+ // 2. Add to quads_settings
+ $slot_entry = array(
+ 'code' => $code,
+ 'label' => $title,
+ 'position' => $position,
+ 'dimensions' => $dimensions,
+ 'ad_type' => 'plain_text',
+ );
+ if ( isset( $args['settings'] ) && is_array( $args['settings'] ) ) {
+ $slot_entry = array_merge( $slot_entry, $args['settings'] );
+ }
+ $settings['ads'][ $slot_key ] = $slot_entry;
+ update_option( 'quads_settings', $settings );
+
+ // 3. Cache purge
+ if ( $args['purge_cache'] ?? true ) {
+ self::purge_ad_cache();
+ }
+
+ return array(
+ 'success' => true,
+ 'slot_key' => $slot_key,
+ 'post_id' => $post_id,
+ 'title' => $title,
+ 'position' => $position,
+ 'detected_network' => EMCP_Tools_Ads_Read_Operations::parse_ad_code( $code ),
+ 'message' => sprintf( 'Ad slot "%s" created successfully.', $slot_key ),
+ );
+ }
+
+ /**
+ * Delete an ad slot with safety confirmation guard.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function delete_ad( array $args ): array {
+ $id = trim( (string) ( $args['id'] ?? '' ) );
+ $confirm = ! empty( $args['confirm'] );
+
+ if ( '' === $id ) {
+ return array( 'success' => false, 'error' => 'Argument "id" is required.' );
+ }
+
+ if ( ! $confirm ) {
+ return array(
+ 'success' => false,
+ 'warning' => 'Confirmation required. Pass "confirm": true to delete this ad unit.',
+ 'id' => $id,
+ );
+ }
+
+ $settings = get_option( 'quads_settings', array() );
+ $slot_key = null;
+ $post_id = 0;
+
+ if ( isset( $settings['ads'][ $id ] ) ) {
+ $slot_key = $id;
+ unset( $settings['ads'][ $id ] );
+ update_option( 'quads_settings', $settings );
+
+ if ( function_exists( 'get_posts' ) ) {
+ $found = get_posts(
+ array(
+ 'post_type' => 'quads-ads',
+ 'meta_key' => 'quads_ad_old_id',
+ 'meta_value' => $slot_key,
+ 'numberposts' => 1,
+ )
+ );
+ if ( ! empty( $found ) ) {
+ $post_id = $found[0]->ID;
+ }
+ }
+ } elseif ( is_numeric( $id ) ) {
+ $post_id = (int) $id;
+ if ( function_exists( 'get_post_meta' ) ) {
+ $old_id = (string) get_post_meta( $post_id, 'quads_ad_old_id', true );
+ if ( ! empty( $old_id ) && isset( $settings['ads'][ $old_id ] ) ) {
+ $slot_key = $old_id;
+ unset( $settings['ads'][ $old_id ] );
+ update_option( 'quads_settings', $settings );
+ }
+ }
+ }
+
+ if ( $post_id && function_exists( 'wp_delete_post' ) ) {
+ wp_delete_post( $post_id, true );
+ }
+
+ if ( $args['purge_cache'] ?? true ) {
+ self::purge_ad_cache();
+ }
+
+ return array(
+ 'success' => true,
+ 'slot_key' => $slot_key ?? $id,
+ 'post_id' => $post_id,
+ 'message' => sprintf( 'Ad slot "%s" deleted successfully.', $id ),
+ );
+ }
+
+ /**
+ * Set or append records to /ads.txt.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function set_ads_txt( array $args ): array {
+ $current = EMCP_Tools_Ads_Read_Operations::get_ads_txt();
+ $raw = $current['raw'];
+
+ // Backup
+ if ( $args['backup'] ?? true ) {
+ update_option(
+ 'emcp_tools_ads_txt_backup',
+ array(
+ 'timestamp' => time(),
+ 'content' => $raw,
+ )
+ );
+ }
+
+ $new_content = '';
+ if ( isset( $args['content'] ) && is_string( $args['content'] ) ) {
+ $new_content = trim( $args['content'] );
+ } elseif ( ! empty( $args['append_records'] ) && is_array( $args['append_records'] ) ) {
+ $existing_lines = array_filter( array_map( 'trim', explode( "\n", str_replace( "\r", '', $raw ) ) ) );
+ foreach ( $args['append_records'] as $rec ) {
+ $rec = trim( (string) $rec );
+ if ( ! empty( $rec ) && ! in_array( $rec, $existing_lines, true ) ) {
+ $existing_lines[] = $rec;
+ }
+ }
+ $new_content = implode( "\n", $existing_lines );
+ } else {
+ return array( 'success' => false, 'error' => 'Must provide either "content" or "append_records".' );
+ }
+
+ // Syntax validation
+ $validation_warnings = array();
+ $lines = explode( "\n", $new_content );
+ foreach ( $lines as $idx => $line ) {
+ $t = trim( $line );
+ if ( '' === $t || 0 === strpos( $t, '#' ) ) {
+ continue;
+ }
+ $parts = array_map( 'trim', explode( ',', $t ) );
+ if ( count( $parts ) < 3 ) {
+ $validation_warnings[] = sprintf( 'Line %d: insufficient fields: "%s"', $idx + 1, $t );
+ } elseif ( ! in_array( strtoupper( $parts[2] ), array( 'DIRECT', 'RESELLER' ), true ) ) {
+ $validation_warnings[] = sprintf( 'Line %d: invalid relationship "%s"', $idx + 1, $parts[2] );
+ }
+ }
+
+ // 1. Save to option
+ update_option( 'emcp_tools_ads_txt', $new_content );
+
+ // 2. Physical write if ABSPATH is available
+ $file_written = false;
+ if ( defined( 'ABSPATH' ) ) {
+ $file_path = ABSPATH . 'ads.txt';
+ if ( ( file_exists( $file_path ) && is_writable( $file_path ) ) || ( ! file_exists( $file_path ) && is_writable( ABSPATH ) ) ) {
+ $file_written = ( false !== @file_put_contents( $file_path, $new_content ) );
+ }
+ }
+
+ // 3. Purge cache
+ if ( $args['purge_cache'] ?? true ) {
+ self::purge_ad_cache();
+ }
+
+ return array(
+ 'success' => true,
+ 'records_count' => count( array_filter( $lines, fn( $l ) => '' !== trim( $l ) && 0 !== strpos( trim( $l ), '#' ) ) ),
+ 'validation_warnings' => $validation_warnings,
+ 'file_written' => $file_written,
+ 'saved_to_option' => true,
+ 'message' => '/ads.txt updated successfully.',
+ );
+ }
+
+ /**
+ * Purge ad transients and page caches.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function purge_ad_cache( array $args = array() ): array {
+ $purged = array();
+
+ // LiteSpeed Cache purge
+ if ( function_exists( 'do_action' ) ) {
+ do_action( 'litespeed_purge_all' );
+ $purged[] = 'litespeed_purge_all';
+ }
+
+ // Object Cache
+ if ( function_exists( 'wp_cache_flush' ) ) {
+ wp_cache_flush();
+ $purged[] = 'wp_cache_flush';
+ }
+
+ // Transient purge for ExoClick
+ if ( function_exists( 'delete_transient' ) ) {
+ global $wpdb;
+ if ( ! empty( $wpdb ) && isset( $wpdb->options ) ) {
+ $keys = $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_emcp_exoclick_%'" );
+ if ( is_array( $keys ) ) {
+ foreach ( $keys as $k ) {
+ $transient_name = str_replace( '_transient_', '', $k );
+ delete_transient( $transient_name );
+ }
+ $purged[] = 'exoclick_transients';
+ }
+ }
+ }
+
+ return array(
+ 'success' => true,
+ 'purged' => $purged,
+ 'message' => 'Ad caches successfully flushed.',
+ );
+ }
+
+ /**
+ * Create an ExoClick zone via REST API with optional automatic installation.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function exoclick_create_zone( array $args ): array {
+ $name = trim( (string) ( $args['name'] ?? '' ) );
+ if ( '' === $name ) {
+ return array( 'success' => false, 'error' => 'Argument "name" is required.' );
+ }
+
+ $token = EMCP_Tools_Ads_Read_Operations::get_exoclick_token( $args );
+ if ( ! $token ) {
+ return array( 'success' => false, 'error' => 'ExoClick API token is required.' );
+ }
+
+ $bearer = EMCP_Tools_Ads_Read_Operations::authenticate_exoclick( $token );
+ if ( is_wp_error( $bearer ) ) {
+ return array( 'success' => false, 'error' => $bearer->get_error_message() );
+ }
+
+ $site_id = $args['idsite'] ?? get_option( 'emcp_tools_exoclick_site_id', 1111220 );
+ $dimensions = $args['dimensions'] ?? '300x250';
+ $format_id = $args['idsub_type_format'] ?? 1; // 1 = Banner
+
+ $body = array(
+ 'name' => $name,
+ 'idsite' => absint( $site_id ),
+ 'idsub_type_format' => absint( $format_id ),
+ 'description' => 'Created by Elementor MCP',
+ );
+
+ $res = wp_remote_post(
+ 'https://api.exoclick.com/v2/zones',
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $bearer,
+ 'Content-Type' => 'application/json',
+ 'User-Agent' => 'EMCP-Tools/WordPress',
+ ),
+ 'body' => wp_json_encode( $body ),
+ 'timeout' => 15,
+ )
+ );
+
+ if ( is_wp_error( $res ) ) {
+ return array( 'success' => false, 'error' => $res->get_error_message() );
+ }
+
+ $data = json_decode( wp_remote_retrieve_body( $res ), true );
+ $zone_id = $data['idzone'] ?? ( $data['result']['idzone'] ?? null );
+
+ if ( ! $zone_id ) {
+ return array(
+ 'success' => false,
+ 'error' => $data['message'] ?? 'Failed to create zone on ExoClick.',
+ 'raw' => $data,
+ );
+ }
+
+ // Generate canonical ExoClick JS banner snippet
+ $snippet = sprintf(
+ '' . "\n" .
+ '' . "\n" .
+ '',
+ $zone_id
+ );
+
+ $install_res = null;
+ $slot_target = $args['install_to_slot'] ?? '';
+ if ( ! empty( $slot_target ) ) {
+ if ( 'create' === $slot_target ) {
+ $install_res = self::create_ad(
+ array(
+ 'title' => $name,
+ 'code' => $snippet,
+ 'dimensions' => $dimensions,
+ )
+ );
+ } else {
+ $install_res = self::update_ad(
+ array(
+ 'id' => $slot_target,
+ 'code' => $snippet,
+ 'title' => $name,
+ 'dimensions' => $dimensions,
+ )
+ );
+ }
+ }
+
+ return array(
+ 'success' => true,
+ 'zone_id' => $zone_id,
+ 'site_id' => $site_id,
+ 'name' => $name,
+ 'tag_code' => $snippet,
+ 'installation' => $install_res,
+ );
+ }
+
+ /**
+ * Verify site with ExoClick.
+ *
+ * @param array $args
+ * @return array
+ */
+ public static function exoclick_verify_site( array $args = array() ): array {
+ $token = EMCP_Tools_Ads_Read_Operations::get_exoclick_token( $args );
+ if ( ! $token ) {
+ return array( 'success' => false, 'error' => 'ExoClick API token is required.' );
+ }
+
+ $bearer = EMCP_Tools_Ads_Read_Operations::authenticate_exoclick( $token );
+ if ( is_wp_error( $bearer ) ) {
+ return array( 'success' => false, 'error' => $bearer->get_error_message() );
+ }
+
+ $site_id = $args['idsite'] ?? get_option( 'emcp_tools_exoclick_site_id', 1111220 );
+
+ // Step 1: Trigger verification check
+ $res = wp_remote_post(
+ 'https://api.exoclick.com/v2/sites/url-verification',
+ array(
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $bearer,
+ 'Content-Type' => 'application/json',
+ 'User-Agent' => 'EMCP-Tools/WordPress',
+ ),
+ 'body' => wp_json_encode( array( 'idsite' => absint( $site_id ) ) ),
+ 'timeout' => 15,
+ )
+ );
+
+ if ( is_wp_error( $res ) ) {
+ return array( 'success' => false, 'error' => $res->get_error_message() );
+ }
+
+ $data = json_decode( wp_remote_retrieve_body( $res ), true );
+ return array(
+ 'success' => true,
+ 'site_id' => $site_id,
+ 'result' => $data,
+ );
+ }
+}
diff --git a/includes/abilities/class-ability-registrar.php b/includes/abilities/class-ability-registrar.php
index 6992b21..0ca3dc1 100644
--- a/includes/abilities/class-ability-registrar.php
+++ b/includes/abilities/class-ability-registrar.php
@@ -282,6 +282,13 @@ class EMCP_Tools_Ability_Registrar {
$this->ability_names = array_merge( $this->ability_names, $comic_easel->get_ability_names() );
}
+ // Ads & Monetization abilities (WP Quads, dynamic ads.txt, ExoClick).
+ if ( class_exists( 'EMCP_Tools_Ads_Integration' ) && EMCP_Tools_Ads_Integration::is_active() ) {
+ $ads_integration = new EMCP_Tools_Ads_Integration();
+ $ads_integration->register();
+ $this->ability_names = array_merge( $this->ability_names, $ads_integration->get_ability_names() );
+ }
+
// Forms-tab integrations. CF7 is free; the five entry-storing plugins are
// Pro. Each registers only when its plugin is active (is_available()).
$form_integrations = array();
diff --git a/includes/class-pro-loader.php b/includes/class-pro-loader.php
index 91b8a0f..f155876 100644
--- a/includes/class-pro-loader.php
+++ b/includes/class-pro-loader.php
@@ -95,6 +95,9 @@ final class EMCP_Tools_Pro_Loader {
'includes/abilities/comic/class-comic-read-operations.php',
'includes/abilities/comic/class-comic-write-operations.php',
'includes/abilities/comic/class-comic-easel-integration.php',
+ 'includes/abilities/ads/class-ads-read-operations.php',
+ 'includes/abilities/ads/class-ads-write-operations.php',
+ 'includes/abilities/ads/class-ads-integration.php',
'includes/abilities/forms/class-wpforms-integration.php',
'includes/abilities/forms/class-gravityforms-integration.php',
'includes/abilities/forms/class-fluentforms-integration.php',
diff --git a/skills/emcp-ads/SKILL.md b/skills/emcp-ads/SKILL.md
new file mode 100644
index 0000000..a1ef289
--- /dev/null
+++ b/skills/emcp-ads/SKILL.md
@@ -0,0 +1,131 @@
+---
+name: "Ad Monetization & ExoClick Manager"
+description: "Manage WP Quads ad slots, dynamic ads.txt, ExoClick REST API zones, reporting, responsive ad styling, and cache invalidation"
+---
+# Ad Monetization & ExoClick Manager
+
+Teach AI agents how to manage, audit, and optimize website advertising inventory, WP Quads ad units, dynamic `/ads.txt` records, and ExoClick REST API integrations across WordPress.
+
+## Architecture & Data Flow
+
+### 1. WP Quads Dual-Write Synchronization
+WP Quads operates with a split data architecture:
+- **Custom Post Type**: `quads-ads` posts store `code`, `position`, `dimensions`, and `quads_ad_old_id` (e.g. `ad1`, `ad2`) in `wp_postmeta`.
+- **Serialized Settings**: `quads_settings` option in `wp_options` stores an `ads` associative array keyed by slot ID (`ad1`, `ad2`, ...).
+- **Critical Rule**: Updating only one side leads to cache desynchronization, stale banner codes, or blank slots on frontend pages. Always use `emcp-tools/ads-write` (`update-ad` / `create-ad`), which enforces simultaneous dual-writes to both postmeta and serialized options.
+
+### 2. Standard Responsive Placements & Dimensions
+When placing ads in comic or blog layouts, maintain strict layout integrity:
+- **Above Comic**: `728x90` Leaderboard. Wrap in responsive container centered above the primary canvas.
+- **Under Comic**: `300x250` Medium Rectangle. Placed immediately after navigation or author notes.
+- **Sidebars (Left & Right)**: `160x600` Wide Skyscrapers. Anchored in layout rails without overlapping main comic panels.
+- **Sticky Mobile Footer**: Anchored floating banner (`eas6a97888e10` or `300x100`/`300x50`) hidden on desktop breakpoints.
+- **Popunder (ExoClick PopMagic)**: `idzone: 6020536`. Always enforce frequency capping (minimum 24-hour cookie cooldown) to preserve user retention.
+
+### 3. IAB ads.txt Management
+- **Syntax**: `, , , `
+ - Example: `exoclick.com, 1111220, DIRECT, f6e6255c27770857`
+ - Relationship must strictly be `DIRECT` or `RESELLER`.
+- **Dual Persistence**: The plugin writes to `ABSPATH . 'ads.txt'` if filesystem permissions permit, and always synchronizes with the WordPress database option `emcp_tools_ads_txt` to ensure uninterrupted serving across all hosting configurations.
+
+### 4. Cache Purging Protocol
+Whenever ad tags, zone IDs, or `/ads.txt` are modified:
+1. Purge LiteSpeed page cache: `do_action('litespeed_purge_all')`
+2. Flush WordPress object cache: `wp_cache_flush()`
+3. Flush transient API tokens: `purge-ad-cache`
+
+---
+
+## MCP Tools Reference
+
+### `emcp-tools/ads-read`
+Call with no `operation` to inspect full operation schemas and argument hints.
+
+- `list-ads`: List all registered ad units from WP Quads, including detected ad networks (`ExoClick`, `JuicyAds`, `TrafficStars`), slot keys, dimensions, and active statuses.
+ ```json
+ { "operation": "list-ads", "arguments": { "status": "all" } }
+ ```
+- `get-ad`: Get complete configuration, dimensions, code, and placement for a specific ad slot by Post ID or Quads key.
+ ```json
+ { "operation": "get-ad", "arguments": { "id": "ad1" } }
+ ```
+- `get-ads-txt`: Parse `/ads.txt` records, validate IAB formatting, and inspect authorized digital sellers.
+ ```json
+ { "operation": "get-ads-txt", "arguments": { "validate_syntax": true } }
+ ```
+- `audit-monetization`: Diagnostic scan checking ads.txt health, active unit count, multi-network script conflicts, and responsive styling.
+ ```json
+ { "operation": "audit-monetization", "arguments": {} }
+ ```
+- `exoclick-list-zones`: Query ExoClick REST API for registered zones, IDs, types, and statuses.
+ ```json
+ { "operation": "exoclick-list-zones", "arguments": { "idsite": 1111220 } }
+ ```
+- `exoclick-get-stats`: Query ExoClick reporting API for impressions, clicks, CTR, eCPM, and revenue metrics.
+ ```json
+ { "operation": "exoclick-get-stats", "arguments": { "date_from": "2026-09-01", "date_to": "2026-09-05" } }
+ ```
+
+---
+
+### `emcp-tools/ads-write`
+Call with no `operation` to inspect full schemas.
+
+- `update-ad`: Dual-write code, title, or position to WP Quads postmeta and `quads_settings`, automatically purging page cache.
+ ```json
+ {
+ "operation": "update-ad",
+ "arguments": {
+ "id": "ad1",
+ "code": "",
+ "title": "Above Comic 728x90",
+ "purge_cache": true
+ }
+ }
+ ```
+- `create-ad`: Create a new ad unit across both posts and serialized settings.
+ ```json
+ {
+ "operation": "create-ad",
+ "arguments": {
+ "title": "Sticky Mobile Footer",
+ "code": "",
+ "slot_key": "ad11",
+ "position": "custom"
+ }
+ }
+ ```
+- `delete-ad`: Delete ad unit from WP Quads and settings (`confirm: true` required for safety).
+ ```json
+ { "operation": "delete-ad", "arguments": { "id": "ad11", "confirm": true } }
+ ```
+- `set-ads-txt`: Replace or append lines to `/ads.txt` with IAB syntax pre-validation.
+ ```json
+ {
+ "operation": "set-ads-txt",
+ "arguments": {
+ "append_records": [
+ "exoclick.com, 1111220, DIRECT, f6e6255c27770857"
+ ]
+ }
+ }
+ ```
+- `purge-ad-cache`: Clear LiteSpeed page cache, WP object cache, and temporary transients.
+ ```json
+ { "operation": "purge-ad-cache", "arguments": { "all": true } }
+ ```
+- `exoclick-create-zone`: Create a new ad zone via ExoClick API and optionally wire it into a WP Quads slot.
+ ```json
+ {
+ "operation": "exoclick-create-zone",
+ "arguments": {
+ "name": "Under Comic 300x250",
+ "dimensions": "300x250",
+ "install_to_slot": "ad2"
+ }
+ }
+ ```
+- `exoclick-verify-site`: Verify site ownership with ExoClick via API verification check.
+ ```json
+ { "operation": "exoclick-verify-site", "arguments": { "idsite": 1111220 } }
+ ```
diff --git a/tests/test-ads-mcp.php b/tests/test-ads-mcp.php
new file mode 100644
index 0000000..0469d3b
--- /dev/null
+++ b/tests/test-ads-mcp.php
@@ -0,0 +1,463 @@
+code = $c; $this->message = $m; }
+ public function get_error_message() { return $this->message; }
+ public function get_error_code() { return $this->code; }
+}
+function is_wp_error( $t ) { return $t instanceof WP_Error; }
+
+function get_post( $id ) {
+ return $GLOBALS['wp_posts'][ $id ] ?? null;
+}
+
+function get_the_title( $id ) {
+ return $GLOBALS['wp_posts'][ $id ]->post_title ?? '';
+}
+
+function get_posts( $args = array() ) {
+ $results = array();
+ $type = $args['post_type'] ?? 'post';
+ $meta_k = $args['meta_key'] ?? '';
+ $meta_v = $args['meta_value'] ?? '';
+
+ foreach ( $GLOBALS['wp_posts'] as $p ) {
+ if ( $type && $p->post_type !== $type ) continue;
+ if ( $meta_k ) {
+ $val = get_post_meta( $p->ID, $meta_k, true );
+ if ( $val != $meta_v ) continue;
+ }
+ $results[] = $p;
+ }
+ return $results;
+}
+
+function wp_insert_post( $args, $wp_error = false ) {
+ $id = ++$GLOBALS['wp_post_counter'];
+ $p = new WP_Post();
+ $p->ID = $id;
+ $p->post_title = $args['post_title'] ?? '';
+ $p->post_name = sanitize_title( $p->post_title );
+ $p->post_type = $args['post_type'] ?? 'quads-ads';
+ $p->post_status = $args['post_status'] ?? 'publish';
+ $GLOBALS['wp_posts'][ $id ] = $p;
+ return $id;
+}
+
+function wp_update_post( $args ) {
+ $id = $args['ID'] ?? 0;
+ if ( ! isset( $GLOBALS['wp_posts'][ $id ] ) ) return false;
+ $p = $GLOBALS['wp_posts'][ $id ];
+ if ( isset( $args['post_title'] ) ) {
+ $p->post_title = $args['post_title'];
+ $p->post_name = sanitize_title( $p->post_title );
+ }
+ return $id;
+}
+
+function wp_delete_post( $id, $force = false ) {
+ if ( isset( $GLOBALS['wp_posts'][ $id ] ) ) {
+ unset( $GLOBALS['wp_posts'][ $id ] );
+ unset( $GLOBALS['wp_postmeta'][ $id ] );
+ return true;
+ }
+ return false;
+}
+
+function get_post_meta( $post_id, $key = '', $single = false ) {
+ if ( $key ) {
+ return $GLOBALS['wp_postmeta'][ $post_id ][ $key ] ?? '';
+ }
+ return $GLOBALS['wp_postmeta'][ $post_id ] ?? array();
+}
+
+function update_post_meta( $post_id, $key, $val ) {
+ $GLOBALS['wp_postmeta'][ $post_id ][ $key ] = $val;
+ return true;
+}
+
+// HTTP Mocking
+function wp_remote_retrieve_body( $resp ) {
+ return is_array( $resp ) ? ( $resp['body'] ?? '' ) : '';
+}
+function wp_remote_retrieve_response_code( $resp ) {
+ return is_array( $resp ) ? ( $resp['response']['code'] ?? 200 ) : 200;
+}
+
+function wp_remote_post( $url, $args = array() ) {
+ // Mock login endpoint
+ if ( strpos( $url, 'api.exoclick.com/v2/login' ) !== false ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => json_encode( array(
+ 'token' => 'mock_bearer_token_xyz_123',
+ 'expires_in' => 3600,
+ ) ),
+ );
+ }
+ // Mock zone creation
+ if ( strpos( $url, 'api.exoclick.com/v2/zones' ) !== false ) {
+ $data = json_decode( $args['body'] ?? '{}', true );
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => json_encode( array(
+ 'idzone' => 6020599,
+ 'name' => $data['name'] ?? 'Test Zone',
+ 'idsite' => $data['idsite'] ?? 1111220,
+ ) ),
+ );
+ }
+ // Mock URL verification
+ if ( strpos( $url, 'api.exoclick.com/v2/sites/url-verification' ) !== false ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => json_encode( array(
+ 'success' => true,
+ 'status' => 1,
+ 'message' => 'Site ownership successfully verified.',
+ ) ),
+ );
+ }
+ return array( 'response' => array( 'code' => 200 ), 'body' => '{}' );
+}
+
+function wp_remote_get( $url, $args = array() ) {
+ // Mock zones list
+ if ( strpos( $url, 'api.exoclick.com/v2/zones' ) !== false ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => json_encode( array(
+ 'result' => array(
+ array( 'idzone' => 6020542, 'name' => 'Above Comic - 728x90', 'status' => 1 ),
+ array( 'idzone' => 6020532, 'name' => 'Under Comic - 300x250', 'status' => 1 ),
+ ),
+ ) ),
+ );
+ }
+ // Mock reporting stats
+ if ( strpos( $url, 'api.exoclick.com/v2/statistics' ) !== false ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => json_encode( array(
+ 'result' => array(
+ array( 'impressions' => 150240, 'clicks' => 312, 'ctr' => 0.21, 'revenue' => 45.80 ),
+ ),
+ ) ),
+ );
+ }
+ // Mock live ads.txt fetch
+ if ( strpos( $url, '/ads.txt' ) !== false ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => "exoclick.com, 1111220, DIRECT, f6e6255c27770857\njuicyads.com, 119864, DIRECT\n",
+ );
+ }
+ return array( 'response' => array( 'code' => 200 ), 'body' => '' );
+}
+
+function emcp_tools_register_ability( $name, $conf ) {
+ $GLOBALS['abilities'][ $name ] = $conf;
+}
+
+// Load classes under test
+require_once __DIR__ . '/../includes/abilities/ads/class-ads-read-operations.php';
+require_once __DIR__ . '/../includes/abilities/ads/class-ads-write-operations.php';
+require_once __DIR__ . '/../includes/abilities/ads/class-ads-integration.php';
+
+echo "=== TEST 1: Ad Network Detection & Parsing (parse_ad_code) ===\n";
+// 1. ExoClick Banner
+$exo_tag = '' .
+ '' .
+ '';
+$parsed_exo = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $exo_tag );
+assert( $parsed_exo['network'] === 'ExoClick' );
+assert( $parsed_exo['zone_id'] === '6020542' );
+assert( $parsed_exo['dimensions'] === '728x90' );
+
+// 2. ExoClick Popunder
+$exo_pop = '';
+$parsed_pop = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $exo_pop );
+assert( $parsed_pop['network'] === 'ExoClick' );
+assert( $parsed_pop['zone_id'] === '6020536' );
+
+// 3. JuicyAds Legacy
+$juicy_tag = '' .
+ '';
+$parsed_juicy = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $juicy_tag );
+assert( $parsed_juicy['network'] === 'JuicyAds' );
+assert( $parsed_juicy['zone_id'] === '1069796' );
+assert( $parsed_juicy['dimensions'] === '160x600' );
+
+// 4. TrafficStars
+$ts_tag = '';
+$parsed_ts = EMCP_Tools_Ads_Read_Operations::parse_ad_code( $ts_tag );
+assert( $parsed_ts['network'] === 'TrafficStars' );
+
+echo "PASS: Ad network detection and dimension parsing verified!\n\n";
+
+echo "=== TEST 2: Schema Completeness & Discovery Catalog ===\n";
+$read_schema = EMCP_Tools_Ads_Read_Operations::op_schema();
+$write_schema = EMCP_Tools_Ads_Write_Operations::op_schema();
+
+assert( count( $read_schema ) === 6 );
+assert( isset( $read_schema['list-ads'], $read_schema['get-ad'], $read_schema['get-ads-txt'], $read_schema['audit-monetization'], $read_schema['exoclick-list-zones'], $read_schema['exoclick-get-stats'] ) );
+
+assert( count( $write_schema ) === 7 );
+assert( isset( $write_schema['update-ad'], $write_schema['create-ad'], $write_schema['delete-ad'], $write_schema['set-ads-txt'], $write_schema['purge-ad-cache'], $write_schema['exoclick-create-zone'], $write_schema['exoclick-verify-site'] ) );
+
+echo "PASS: All 13 read & write operation schemas verified!\n\n";
+
+echo "=== TEST 3: WP Quads Create, Update & Dual-Write Synchronization ===\n";
+// 1. Create ad slot
+$create_res = EMCP_Tools_Ads_Write_Operations::create_ad( array(
+ 'title' => 'Above Comic 728x90',
+ 'code' => $exo_tag,
+ 'slot_key' => 'ad1',
+ 'position' => 'custom',
+ 'dimensions' => '728x90',
+) );
+
+assert( $create_res['success'] === true );
+assert( $create_res['slot_key'] === 'ad1' );
+$post_id = $create_res['post_id'];
+assert( $post_id > 0 );
+
+// Verify quads_settings option
+$settings = get_option( 'quads_settings' );
+assert( isset( $settings['ads']['ad1'] ) );
+assert( $settings['ads']['ad1']['code'] === $exo_tag );
+assert( $settings['ads']['ad1']['label'] === 'Above Comic 728x90' );
+
+// Verify postmeta
+assert( get_post_meta( $post_id, 'code', true ) === $exo_tag );
+assert( get_post_meta( $post_id, 'quads_ad_old_id', true ) === 'ad1' );
+
+// 2. Read ad slot
+$read_res = EMCP_Tools_Ads_Read_Operations::get_ad( array( 'id' => 'ad1' ) );
+assert( $read_res['success'] === true );
+assert( $read_res['slot_key'] === 'ad1' );
+assert( $read_res['parsed']['network'] === 'ExoClick' );
+assert( $read_res['parsed']['zone_id'] === '6020542' );
+
+// 3. Update ad slot
+$updated_exo_tag = str_replace( '6020542', '6020599', $exo_tag );
+$update_res = EMCP_Tools_Ads_Write_Operations::update_ad( array(
+ 'id' => 'ad1',
+ 'code' => $updated_exo_tag,
+ 'title' => 'Above Comic - ExoClick 6020599',
+) );
+
+assert( $update_res['success'] === true );
+// Verify dual-write sync after update
+$settings = get_option( 'quads_settings' );
+assert( $settings['ads']['ad1']['code'] === $updated_exo_tag );
+assert( $settings['ads']['ad1']['label'] === 'Above Comic - ExoClick 6020599' );
+assert( get_post_meta( $post_id, 'code', true ) === $updated_exo_tag );
+
+// 4. List ads
+$list_res = EMCP_Tools_Ads_Read_Operations::list_ads();
+assert( $list_res['success'] === true );
+assert( $list_res['total_ads'] === 1 );
+assert( $list_res['ads'][0]['zone_id'] === '6020599' );
+
+echo "PASS: Create, Update, and Dual-Write synchronization verified!\n\n";
+
+echo "=== TEST 4: Delete Ad Unit with Confirmation Guard ===\n";
+// Attempt delete without confirm: true
+$unconfirmed = EMCP_Tools_Ads_Write_Operations::delete_ad( array( 'id' => 'ad1' ) );
+assert( $unconfirmed['success'] === false );
+assert( strpos( $unconfirmed['warning'], 'Confirmation required' ) !== false );
+assert( isset( $GLOBALS['wp_posts'][ $post_id ] ) ); // Still exists
+
+// Delete with confirm: true
+$confirmed = EMCP_Tools_Ads_Write_Operations::delete_ad( array( 'id' => 'ad1', 'confirm' => true ) );
+assert( $confirmed['success'] === true );
+assert( ! isset( $GLOBALS['wp_posts'][ $post_id ] ) ); // Deleted from posts
+$settings = get_option( 'quads_settings' );
+assert( ! isset( $settings['ads']['ad1'] ) ); // Deleted from quads_settings
+
+echo "PASS: Safety confirmation guard and complete ad unit deletion verified!\n\n";
+
+echo "=== TEST 5: ads.txt Parser, Syntax Validator & Dual-Write ===\n";
+// 1. Set full ads.txt content
+$sample_ads_txt = "# Authorized Digital Sellers for shad-base.com\n" .
+ "exoclick.com, 1111220, DIRECT, f6e6255c27770857\n" .
+ "trafficstars.com, 48912, DIRECT\n" .
+ "badline_format\n" .
+ "invalidrel.com, 99999, PARTNER\n";
+
+$set_res = EMCP_Tools_Ads_Write_Operations::set_ads_txt( array(
+ 'content' => $sample_ads_txt,
+) );
+assert( $set_res['success'] === true );
+assert( $set_res['saved_to_option'] === true );
+assert( count( $set_res['validation_warnings'] ) === 2 );
+
+// 2. Read and parse ads.txt
+$parsed_txt = EMCP_Tools_Ads_Read_Operations::get_ads_txt();
+assert( $parsed_txt['success'] === true );
+assert( $parsed_txt['total_records'] === 3 );
+assert( $parsed_txt['records'][0]['domain'] === 'exoclick.com' );
+assert( $parsed_txt['records'][0]['publisher_id'] === '1111220' );
+assert( $parsed_txt['records'][0]['relationship'] === 'DIRECT' );
+assert( $parsed_txt['records'][0]['certification_authority_id'] === 'f6e6255c27770857' );
+assert( $parsed_txt['records'][0]['is_valid'] === true );
+
+// 3. Append record
+$append_res = EMCP_Tools_Ads_Write_Operations::set_ads_txt( array(
+ 'append_records' => array( 'juicyads.com, 119864, DIRECT' ),
+) );
+assert( $append_res['success'] === true );
+$re_parsed = EMCP_Tools_Ads_Read_Operations::get_ads_txt();
+assert( strpos( $re_parsed['raw'], 'juicyads.com, 119864, DIRECT' ) !== false );
+
+echo "PASS: ads.txt parser, syntax validation, backup, and append verified!\n\n";
+
+echo "=== TEST 6: Monetization Diagnostic Audit ===\n";
+// With only ads.txt and no ads
+$audit1 = EMCP_Tools_Ads_Read_Operations::audit_monetization();
+assert( $audit1['success'] === true );
+assert( $audit1['status'] === 'attention_required' );
+assert( in_array( 'No active ad units found in WP Quads.', $audit1['issues'], true ) );
+
+// Add active ExoClick ad unit
+EMCP_Tools_Ads_Write_Operations::create_ad( array(
+ 'title' => 'Main Banner',
+ 'code' => $exo_tag,
+) );
+$audit2 = EMCP_Tools_Ads_Read_Operations::audit_monetization();
+assert( $audit2['success'] === true );
+assert( $audit2['active_ad_units'] === 1 );
+
+echo "PASS: Monetization diagnostic audit verified!\n\n";
+
+echo "=== TEST 7: Cache Purging (LiteSpeed & Object Cache) ===\n";
+$GLOBALS['actions_fired'] = array();
+$purge_res = EMCP_Tools_Ads_Write_Operations::purge_ad_cache();
+assert( $purge_res['success'] === true );
+assert( in_array( 'litespeed_purge_all', $GLOBALS['actions_fired'], true ) );
+assert( in_array( 'wp_cache_flush', $GLOBALS['actions_fired'], true ) );
+
+echo "PASS: Cache purging operations verified!\n\n";
+
+echo "=== TEST 8: ExoClick REST API Integration Mock ===\n";
+// Set API token
+update_option( 'emcp_tools_exoclick_api_token', 'test_api_token_12345' );
+update_option( 'emcp_tools_exoclick_site_id', 1111220 );
+
+// 1. Zones list
+$zones_res = EMCP_Tools_Ads_Read_Operations::exoclick_list_zones();
+assert( $zones_res['success'] === true );
+assert( count( $zones_res['zones'] ) === 2 );
+
+// 2. Reporting stats
+$stats_res = EMCP_Tools_Ads_Read_Operations::exoclick_get_stats();
+assert( $stats_res['success'] === true );
+assert( $stats_res['stats'][0]['impressions'] === 150240 );
+
+// 3. Create Zone with automatic slot installation
+$zone_create = EMCP_Tools_Ads_Write_Operations::exoclick_create_zone( array(
+ 'name' => 'Sidebar Ad',
+ 'dimensions' => '160x600',
+ 'install_to_slot' => 'create',
+) );
+assert( $zone_create['success'] === true );
+assert( $zone_create['zone_id'] === 6020599 );
+assert( strpos( $zone_create['tag_code'], 'data-zoneid="6020599"' ) !== false );
+assert( $zone_create['installation']['success'] === true );
+
+// 4. Site Verification
+$verify_res = EMCP_Tools_Ads_Write_Operations::exoclick_verify_site();
+assert( $verify_res['success'] === true );
+assert( $verify_res['result']['status'] === 1 );
+
+echo "PASS: ExoClick REST API zones, stats, zone creation, and site verification verified!\n\n";
+
+echo "=== TEST 9: Abilities API Dispatcher Tools (emcp-tools/ads-read & ads-write) ===\n";
+$integration = new EMCP_Tools_Ads_Integration();
+$integration->register();
+
+assert( isset( $GLOBALS['abilities']['emcp-tools/ads-read'] ) );
+assert( isset( $GLOBALS['abilities']['emcp-tools/ads-write'] ) );
+
+// 1. Catalog discovery call (empty operation)
+$read_catalog = $integration->execute_read( array() );
+assert( isset( $read_catalog['tool'], $read_catalog['operations'] ) );
+assert( count( $read_catalog['operations'] ) === 6 );
+assert( isset( $read_catalog['operations']['list-ads']['schema'] ) );
+
+$write_catalog = $integration->execute_write( array() );
+assert( isset( $write_catalog['tool'], $write_catalog['operations'] ) );
+assert( count( $write_catalog['operations'] ) === 7 );
+
+// 2. Dispatcher execution
+$dispatched_read = $integration->execute_read( array(
+ 'operation' => 'get-ads-txt',
+ 'arguments' => array(),
+) );
+assert( $dispatched_read['success'] === true );
+assert( $dispatched_read['total_records'] > 0 );
+
+$dispatched_write = $integration->execute_write( array(
+ 'operation' => 'purge-ad-cache',
+ 'arguments' => array( 'all' => true ),
+) );
+assert( $dispatched_write['success'] === true );
+
+echo "PASS: Abilities API registration, catalog introspection, and dispatchers verified!\n\n";
+
+echo "ALL 9 ADS & MONETIZATION MCP TEST SUITES PASSED 100%!\n";
+
+if ( file_exists( ABSPATH . "ads.txt" ) ) {
+ @unlink( ABSPATH . "ads.txt" );
+}