using GoogleApi.Configuration; using GoogleApi.Models; using Microsoft.Extensions.Options; using Google.Ads.GoogleAds; using Google.Ads.GoogleAds.Lib; using Google.Ads.GoogleAds.V22.Errors; using Google.Ads.GoogleAds.V22.Services; namespace GoogleApi.Services; using GAdsServices = global::Google.Ads.GoogleAds.Services; /// /// Reporting service for pulling campaign performance metrics from Google Ads. /// Supports both real API calls and emulated responses for development. /// /// Operations: /// - GetCampaignReport: Daily metrics for a specific campaign /// - GetAccountReport: Daily metrics across all campaigns in an account /// - GetCampaignList: Campaign status/budget summary (lightweight) /// public sealed class ReportingService { private readonly GoogleAdsConfig _config; private readonly GoogleAdsClientFactory _clientFactory; private readonly ILogger _logger; public ReportingService( IOptions config, GoogleAdsClientFactory clientFactory, ILogger logger) { _config = config.Value; _clientFactory = clientFactory; _logger = logger; } /// /// Get daily campaign performance report. /// Returns impressions, clicks, spend, conversions, conversion value per day. /// public async Task GetCampaignReportAsync( ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); if (string.IsNullOrWhiteSpace(payload.CampaignId)) return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd"); var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd"); if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId)) return await GetCampaignReportRealAsync(payload.CampaignId, startDate, endDate, context, requestId, ct); return GetCampaignReportEmulated(payload.CampaignId, startDate, endDate, requestId); } /// /// Get daily account-level performance report across all campaigns. /// Used for syncing metrics into tbPerformanceMetric. /// public async Task GetAccountReportAsync( ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd"); var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd"); if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId)) return await GetAccountReportRealAsync(startDate, endDate, context, requestId, ct); return GetAccountReportEmulated(startDate, endDate, requestId); } // ════════════════════════════════════════════════ // Real API Implementation // ════════════════════════════════════════════════ private async Task GetCampaignReportRealAsync( string campaignId, string startDate, string endDate, GoogleAdsContext context, string requestId, CancellationToken ct) { try { var client = _clientFactory.CreateClient(context); var gaService = client.GetService(GAdsServices.V22.GoogleAdsService); // Extract numeric campaign ID from resource name if needed var numericId = campaignId.Contains('/') ? campaignId.Split('/').Last() : campaignId; var query = $@" SELECT campaign.id, campaign.name, campaign.status, segments.date, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.all_conversions, metrics.ctr, metrics.average_cpc FROM campaign WHERE campaign.id = {numericId} AND segments.date BETWEEN '{startDate}' AND '{endDate}' ORDER BY segments.date"; var rows = new List(); var searchRequest = new SearchGoogleAdsRequest { CustomerId = context.CustomerId, Query = query }; var response = gaService.Search(searchRequest); foreach (var row in response) { rows.Add(new { date = row.Segments.Date, campaignId = row.Campaign.Id, campaignName = row.Campaign.Name, campaignStatus = row.Campaign.Status.ToString(), impressions = row.Metrics.Impressions, clicks = row.Metrics.Clicks, costMicros = row.Metrics.CostMicros, spend = row.Metrics.CostMicros / 1_000_000.0, conversions = row.Metrics.Conversions, conversionValue = row.Metrics.ConversionsValue, allConversions = row.Metrics.AllConversions, ctr = row.Metrics.Ctr, averageCpcMicros = row.Metrics.AverageCpc }); } _logger.LogInformation( "[Reporting] Retrieved {Count} rows for campaign {CampaignId} | RequestId={RequestId}", rows.Count, campaignId, requestId); return ProviderResponse.Success(requestId, new { campaignId, dateRange = new { start = startDate, end = endDate }, rows, rowCount = rows.Count, emulated = false }); } catch (GoogleAdsException gex) { _logger.LogError(gex, "[Reporting] Google Ads error for campaign {CampaignId}", campaignId); return HandleGoogleAdsException(gex, requestId); } catch (Exception ex) { _logger.LogError(ex, "[Reporting] Error fetching campaign report"); return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message); } } private async Task GetAccountReportRealAsync( string startDate, string endDate, GoogleAdsContext context, string requestId, CancellationToken ct) { try { var client = _clientFactory.CreateClient(context); var gaService = client.GetService(GAdsServices.V22.GoogleAdsService); var query = $@" SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, segments.date, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.all_conversions FROM campaign WHERE segments.date BETWEEN '{startDate}' AND '{endDate}' AND campaign.status != 'REMOVED' ORDER BY segments.date, campaign.id"; var rows = new List(); var searchRequest = new SearchGoogleAdsRequest { CustomerId = context.CustomerId, Query = query }; var response = gaService.Search(searchRequest); foreach (var row in response) { rows.Add(new { date = row.Segments.Date, campaignId = row.Campaign.Id, campaignName = row.Campaign.Name, campaignStatus = row.Campaign.Status.ToString(), channelType = row.Campaign.AdvertisingChannelType.ToString(), impressions = row.Metrics.Impressions, clicks = row.Metrics.Clicks, costMicros = row.Metrics.CostMicros, spend = row.Metrics.CostMicros / 1_000_000.0, conversions = row.Metrics.Conversions, conversionValue = row.Metrics.ConversionsValue, allConversions = row.Metrics.AllConversions }); } _logger.LogInformation( "[Reporting] Retrieved {Count} rows for account {CustomerId} | RequestId={RequestId}", rows.Count, context.CustomerId, requestId); return ProviderResponse.Success(requestId, new { customerId = context.CustomerId, dateRange = new { start = startDate, end = endDate }, rows, rowCount = rows.Count, emulated = false }); } catch (GoogleAdsException gex) { _logger.LogError(gex, "[Reporting] Google Ads error for account {CustomerId}", context.CustomerId); return HandleGoogleAdsException(gex, requestId); } catch (Exception ex) { _logger.LogError(ex, "[Reporting] Error fetching account report"); return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message); } } // ════════════════════════════════════════════════ // Emulated Responses // ════════════════════════════════════════════════ private ProviderResponse GetCampaignReportEmulated( string campaignId, string startDate, string endDate, string requestId) { _logger.LogInformation("[Reporting] EMULATED: Campaign report for {CampaignId}", campaignId); var rows = GenerateEmulatedDailyRows(startDate, endDate); return ProviderResponse.Success(requestId, new { campaignId, dateRange = new { start = startDate, end = endDate }, rows, rowCount = rows.Count, emulated = true }); } private ProviderResponse GetAccountReportEmulated( string startDate, string endDate, string requestId) { _logger.LogInformation("[Reporting] EMULATED: Account report"); // Generate rows for 3 emulated campaigns var allRows = new List(); var campaigns = new[] { new { id = "emu_camp_001", name = "Search - Brand Terms", channel = "SEARCH" }, new { id = "emu_camp_002", name = "Display - Awareness", channel = "DISPLAY" }, new { id = "emu_camp_003", name = "PMax - Conversions", channel = "PERFORMANCE_MAX" } }; foreach (var camp in campaigns) { var rows = GenerateEmulatedDailyRows(startDate, endDate); foreach (var row in rows) { // Merge campaign info into each row var dict = new Dictionary { ["campaignId"] = camp.id, ["campaignName"] = camp.name, ["campaignStatus"] = "ENABLED", ["channelType"] = camp.channel }; // Merge the row properties var rowJson = System.Text.Json.JsonSerializer.Serialize(row); var rowDict = System.Text.Json.JsonSerializer.Deserialize>(rowJson); if (rowDict != null) foreach (var kv in rowDict) dict[kv.Key] = kv.Value; allRows.Add(dict); } } return ProviderResponse.Success(requestId, new { customerId = "emulated", dateRange = new { start = startDate, end = endDate }, rows = allRows, rowCount = allRows.Count, emulated = true }); } private static List GenerateEmulatedDailyRows(string startDate, string endDate) { var rows = new List(); var rng = new Random(42); // deterministic seed for consistent emulation if (!DateTime.TryParse(startDate, out var start)) start = DateTime.UtcNow.AddDays(-30); if (!DateTime.TryParse(endDate, out var end)) end = DateTime.UtcNow.AddDays(-1); for (var date = start; date <= end; date = date.AddDays(1)) { var impressions = rng.Next(800, 3500); var clicks = (int)(impressions * (0.015 + rng.NextDouble() * 0.04)); var costMicros = (long)(clicks * (450_000 + rng.Next(0, 300_000))); var conversions = Math.Round(clicks * (0.03 + rng.NextDouble() * 0.05), 2); var conversionValue = Math.Round(conversions * (25 + rng.NextDouble() * 75), 2); rows.Add(new { date = date.ToString("yyyy-MM-dd"), impressions, clicks, costMicros, spend = Math.Round(costMicros / 1_000_000.0, 2), conversions, conversionValue, allConversions = Math.Round(conversions * 1.15, 2), ctr = impressions > 0 ? Math.Round((double)clicks / impressions, 4) : 0.0, averageCpcMicros = clicks > 0 ? costMicros / clicks : 0L }); } return rows; } // ════════════════════════════════════════════════ // Error Handling // ════════════════════════════════════════════════ private static ProviderResponse HandleGoogleAdsException(GoogleAdsException gex, string requestId) { var errorDetails = gex.Failure?.Errors?.Select(e => new { errorCode = e.ErrorCode?.ToString(), message = e.Message, trigger = e.Trigger?.StringValue, location = e.Location?.FieldPathElements?.Select(f => f.FieldName).ToArray() }).ToList(); return ProviderResponse.Fail(requestId, "GOOGLE_ADS_ERROR", gex.Message, new { googleRequestId = gex.RequestId, errors = errorDetails }); } } /// /// Payload for reporting operations. /// public sealed class ReportingPayload { public string? CampaignId { get; set; } public string? StartDate { get; set; } public string? EndDate { get; set; } }