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"
};
}