using GoogleApi.Configuration; using GoogleApi.Models; using Microsoft.Extensions.Options; using Google.Ads.GoogleAds.Lib; using Google.Ads.GoogleAds.V22.Services; using Google.Ads.GoogleAds.V22.Common; using Google.Ads.GoogleAds.V22.Enums; using Google.Ads.GoogleAds.V22.Resources; namespace GoogleApi.Services; using GAdsServices = global::Google.Ads.GoogleAds.Services; /// /// Generates keyword-level forecast metrics via Google Ads KeywordPlanIdeaService. /// Used by the wizard to show estimated performance before campaign creation. /// /// EnableRealApi=false → emulated estimates based on budget + keyword count. /// EnableRealApi=true → calls GenerateKeywordForecastMetrics. /// public sealed class KeywordForecastService { private readonly GoogleAdsConfig _config; private readonly GoogleAdsClientFactory _clientFactory; private readonly ILogger _logger; public KeywordForecastService( IOptions config, GoogleAdsClientFactory clientFactory, ILogger logger) { _config = config.Value; _clientFactory = clientFactory; _logger = logger; } public async Task ForecastAsync( KeywordForecastPayload payload, GoogleAdsContext context, CancellationToken ct) { if (payload.Keywords.Count == 0) { _logger.LogWarning("[KeywordForecast] No keywords provided"); return EmptyForecast(); } if (_clientFactory.IsRealApiEnabled) { try { return await ForecastRealAsync(payload, context, ct); } catch (Exception ex) { _logger.LogError(ex, "[KeywordForecast] Real API failed, falling back to emulated"); return ForecastEmulated(payload); } } return ForecastEmulated(payload); } // ================================================================ // Real API: GenerateKeywordForecastMetrics // ================================================================ private async Task ForecastRealAsync( KeywordForecastPayload payload, GoogleAdsContext context, CancellationToken ct) { _logger.LogInformation( "[KeywordForecast] Real API | Keywords={Count} Budget={Budget} Geos={Geos}", payload.Keywords.Count, payload.MonthlyBudget, payload.GeoTargetIds.Count); var client = _clientFactory.CreateClient(context); var service = client.GetService(GAdsServices.V22.KeywordPlanIdeaService); // Build campaign to forecast var campaign = new CampaignToForecast { KeywordPlanNetwork = KeywordPlanNetworkEnum.Types.KeywordPlanNetwork.GoogleSearch }; // Geo targets foreach (var geoId in payload.GeoTargetIds) { campaign.GeoModifiers.Add(new CriterionBidModifier { GeoTargetConstant = GeoTargetConstantName.Format(geoId.ToString()) }); } // Ad group with keywords (cap at 20 for sanity) var adGroup = new ForecastAdGroup(); foreach (var keyword in payload.Keywords.Take(20)) { adGroup.BiddableKeywords.Add(new BiddableKeyword { MaxCpcBidMicros = 2_000_000, // $2.00 default bid for simulation Keyword = new KeywordInfo { Text = keyword, MatchType = KeywordMatchTypeEnum.Types.KeywordMatchType.Broad } }); } campaign.AdGroups.Add(adGroup); var request = new GenerateKeywordForecastMetricsRequest { Campaign = campaign, ForecastPeriod = new DateRange { StartDate = DateTime.UtcNow.ToString("yyyy-MM-dd"), EndDate = DateTime.UtcNow.AddDays(payload.ForecastDays).ToString("yyyy-MM-dd") }, CustomerId = context.CustomerId }; var response = await service.GenerateKeywordForecastMetricsAsync(request); var m = response.CampaignForecastMetrics; // V22 SDK returns non-nullable primitives (0 when no data) var impressions = m.Impressions; var clicks = m.Clicks; var costMicros = m.CostMicros; var conversions = m.Conversions; var avgCpcMicros = m.AverageCpcMicros; var cost = costMicros / 1_000_000m; var avgCpc = avgCpcMicros / 1_000_000m; var ctr = impressions > 0 ? clicks / impressions : 0; var avgCpm = impressions > 0 ? (cost / (decimal)impressions) * 1000m : 0; var cpa = conversions > 0 ? cost / (decimal)conversions : (decimal?)null; _logger.LogInformation( "[KeywordForecast] Real result | Imp={Imp} Clicks={Clicks} Cost={Cost}", impressions, clicks, cost); return new KeywordForecastResponse { Provider = "google", Monthly = new ForecastEstimates { Impressions = impressions, Clicks = clicks, Cost = cost, Conversions = conversions }, Metrics = new ForecastMetrics { AvgCpc = avgCpc, AvgCpm = avgCpm, Ctr = ctr, EstimatedCpa = cpa }, Confidence = "medium", DataSource = "keywordForecast" }; } // ================================================================ // Emulated: budget-proportional estimates with realistic variance // ================================================================ private KeywordForecastResponse ForecastEmulated(KeywordForecastPayload payload) { _logger.LogInformation( "[KeywordForecast] Emulated | Keywords={Count} Budget={Budget}", payload.Keywords.Count, payload.MonthlyBudget); // Realistic Search ranges for SMB tier var keywordFactor = Math.Min(payload.Keywords.Count, 20) / 20.0; var baseCpc = 2.50m - (decimal)(keywordFactor * 1.20); // $1.30 – $2.50 var budget = payload.MonthlyBudget; var clicks = budget > 0 ? (double)(budget / baseCpc) : 0; var impressions = clicks / 0.045; // ~4.5% CTR var conversions = clicks * 0.035; // ~3.5% conv rate var ctr = impressions > 0 ? clicks / impressions : 0; var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0; var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null; // Seeded variance (±15%) — same inputs → same output, but not canned-looking var rng = new Random((int)(budget * 100) + payload.Keywords.Count); var v = 0.85 + (rng.NextDouble() * 0.30); return new KeywordForecastResponse { Provider = "google", Monthly = new ForecastEstimates { Impressions = Math.Round(impressions * v), Clicks = Math.Round(clicks * v), Cost = Math.Round(budget * (decimal)v, 2), Conversions = Math.Round(conversions * v, 1) }, Metrics = new ForecastMetrics { AvgCpc = Math.Round(baseCpc, 2), AvgCpm = Math.Round((decimal)cpm, 2), Ctr = Math.Round(ctr, 4), EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null }, Confidence = "low", DataSource = "emulated" }; } private static KeywordForecastResponse EmptyForecast() => new() { Provider = "google", Confidence = "none", DataSource = "none" }; }