using IntelligenceApi.Models; using System.Diagnostics; using System.Text.Json; namespace IntelligenceApi.Engines.General; /// /// Default spend distribution engine for General (small business) clients. /// /// This is a direct transplant of ForecastService from the Gateway. /// Behavior is identical to the original — existing clients see no change. /// /// Algorithm: /// 1. Fan out to provider APIs in parallel (Google live, others emulated) /// 2. Normalize metrics across providers /// 3. Score each channel using objective-weighted metrics /// 4. Derive allocation percentages (min 15%, max 85%) /// 5. Return sorted channel estimates with recommendation text /// /// No AI cost — runs entirely on rules + provider data. /// public sealed class GeneralEngine : ISpendDistributionEngine { private readonly IHttpClientFactory _http; private readonly ILogger _logger; private const int MIN_ALLOCATION = 15; private const int MAX_ALLOCATION = 85; private static readonly JsonSerializerOptions _jsonOpts = new(JsonSerializerDefaults.Web); public string EngineName => "General"; public GeneralEngine(IHttpClientFactory http, ILogger logger) { _http = http; _logger = logger; } public async Task RecommendAsync( SpendDistributionRequest request, CancellationToken ct) { var sw = Stopwatch.StartNew(); var channels = request.Channels ?? new List { "google_ads" }; var weights = ObjectiveWeights.For(request.Objective); _logger.LogInformation( "[GeneralEngine] Starting | Objective={Obj} Budget={Budget} Channels={Ch}", request.Objective, request.MonthlyBudget, string.Join(",", channels)); // ── Fan out to providers in parallel ── var tasks = new Dictionary>(); foreach (var channel in channels) { tasks[channel] = channel switch { "google_ads" => FetchGoogleAsync(request, ct), "meta" => FetchMetaAsync(request, ct), _ => Task.FromResult(TemplateForecast(channel)) }; } await Task.WhenAll(tasks.Values); var results = tasks.ToDictionary(t => t.Key, t => t.Value.Result); // ── Score and allocate ── var scored = ScoreChannels(results, weights); var allocations = DeriveAllocations(scored); // ── Build response ── var channelAllocations = new List(); foreach (var (channel, result) in results) { var pct = allocations[channel]; var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2); channelAllocations.Add(new ChannelAllocation { Provider = channel, AllocationPercent = pct, AllocatedBudget = allocated, Estimates = new AllocationMetrics { Impressions = result.Impressions, Reach = result.Reach, Clicks = result.Clicks, Conversions = result.Conversions, AvgCpc = result.AvgCpc, AvgCpm = result.AvgCpm, EstimatedCpa = result.EstimatedCpa, Ctr = result.Ctr }, EfficiencyScore = Math.Round(scored[channel], 3), StrengthLabel = GetStrengthLabel(channel, request.Objective), Confidence = result.Confidence, DataSource = result.DataSource }); } channelAllocations.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent)); sw.Stop(); _logger.LogInformation("[GeneralEngine] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds); return new SpendDistributionResponse { Ok = true, Objective = request.Objective, TotalBudget = request.MonthlyBudget, Channels = channelAllocations, Recommendation = BuildRecommendation(channelAllocations, request.Objective), Metadata = new DistributionMeta { GeneratedAt = DateTimeOffset.UtcNow, ForecastPeriod = "30 days", Engine = EngineName } }; } // ════════════════════════════════════════════════ // Provider calls // ════════════════════════════════════════════════ private async Task FetchGoogleAsync( SpendDistributionRequest request, CancellationToken ct) { try { var providerUrl = request.ProviderUrls?.GetValueOrDefault("google_ads") ?? ""; var key = request.InternalKeys?.GetValueOrDefault("google_ads") ?? ""; if (string.IsNullOrWhiteSpace(providerUrl)) return EmulatedGoogle(request); var payload = new { keywords = request.Keywords, geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List(), monthlyBudget = request.MonthlyBudget, currencyCode = "USD", forecastDays = 30 }; var providerRequest = new { operation = "KeywordForecast", requestId = Guid.NewGuid().ToString("N"), payload }; var httpClient = _http.CreateClient(); using var msg = new HttpRequestMessage( HttpMethod.Post, $"{providerUrl}/internal/execute"); msg.Headers.Add("X-Internal-Key", key); msg.Content = new StringContent( JsonSerializer.Serialize(providerRequest, _jsonOpts), System.Text.Encoding.UTF8, "application/json"); using var resp = await httpClient.SendAsync(msg, ct); var body = await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) { _logger.LogWarning("[GeneralEngine] Google provider {Status}", (int)resp.StatusCode); return EmulatedGoogle(request); } using var doc = JsonDocument.Parse(body); var root = doc.RootElement; var data = root.TryGetProperty("data", out var d) ? d : root; var monthly = data.GetProperty("monthly"); var metrics = data.GetProperty("metrics"); return new ProviderResult { Provider = "google_ads", Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0, Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0, Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0, Reach = null, AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0, AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0, Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0, EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null ? cpa.GetDecimal() : null, Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low", DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated" }; } catch (Exception ex) { _logger.LogError(ex, "[GeneralEngine] Google provider call failed"); return EmulatedGoogle(request); } } private static async Task FetchMetaAsync( SpendDistributionRequest request, CancellationToken ct) { // Phase 2: call MetaApi /internal/execute → DeliveryEstimate await Task.CompletedTask; var budget = request.MonthlyBudget; var rng = new Random((int)(budget * 77)); var v = 0.85 + (rng.NextDouble() * 0.30); var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0); var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0; var reach = impressions * 0.42; var clickRate = 0.012 + (rng.NextDouble() * 0.008); var clicks = impressions * clickRate; var conversions = clicks * (0.025 + rng.NextDouble() * 0.015); var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0; var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null; return new ProviderResult { Provider = "meta", Impressions = Math.Round(impressions), Clicks = Math.Round(clicks), Conversions = Math.Round(conversions, 1), Reach = Math.Round(reach), AvgCpc = Math.Round(avgCpc, 2), AvgCpm = Math.Round(cpm, 2), Ctr = Math.Round(clickRate, 4), EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null, Confidence = "low", DataSource = "emulated" }; } private static ProviderResult TemplateForecast(string provider) => new() { Provider = provider, Confidence = "none", DataSource = "template" }; private ProviderResult EmulatedGoogle(SpendDistributionRequest request) { var budget = request.MonthlyBudget; var kwCount = Math.Max(request.Keywords.Count, 1); var rng = new Random((int)(budget * 100) + kwCount); var v = 0.85 + (rng.NextDouble() * 0.30); var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20); var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0; var impressions = clicks / 0.045; var conversions = clicks * 0.035; 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; return new ProviderResult { Provider = "google_ads", Impressions = Math.Round(impressions), Clicks = Math.Round(clicks), Conversions = Math.Round(conversions, 1), 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" }; } // ════════════════════════════════════════════════ // Scoring // ════════════════════════════════════════════════ private static Dictionary ScoreChannels( Dictionary results, MetricWeights w) { var scoreable = results .Where(r => r.Value.DataSource != "template") .ToDictionary(r => r.Key, r => r.Value); if (scoreable.Count == 0) return results.ToDictionary(r => r.Key, _ => 1.0); var maxImp = scoreable.Values.Max(r => r.Impressions); var maxReach = scoreable.Values.Max(r => r.Reach ?? 0); var maxClicks = scoreable.Values.Max(r => r.Clicks); var maxConv = scoreable.Values.Max(r => r.Conversions); var maxCtr = scoreable.Values.Max(r => r.Ctr); var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min(); var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min(); var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0) .Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min(); var scores = new Dictionary(); foreach (var (channel, r) in scoreable) { double score = 0; score += w.Impressions * SafeDiv(r.Impressions, maxImp); score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1); score += w.Clicks * SafeDiv(r.Clicks, maxClicks); score += w.Conversions * SafeDiv(r.Conversions, maxConv); score += w.Ctr * SafeDiv(r.Ctr, maxCtr); score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0); score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0); score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0); scores[channel] = score; } var avg = scores.Values.Average(); foreach (var channel in results.Keys.Except(scoreable.Keys)) scores[channel] = avg * 0.5; return scores; } private static Dictionary DeriveAllocations(Dictionary scores) { var total = scores.Values.Sum(); if (total == 0) { var even = 100 / scores.Count; return scores.ToDictionary(s => s.Key, _ => even); } var raw = scores.ToDictionary( s => s.Key, s => Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, (int)Math.Round(s.Value / total * 100)))); var sum = raw.Values.Sum(); if (sum != 100 && raw.Count > 0) { var diff = 100 - sum; var top = raw.OrderByDescending(r => r.Value).First().Key; raw[top] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[top] + diff)); } return raw; } // ════════════════════════════════════════════════ // Copy helpers (keep identical to Gateway originals) // ════════════════════════════════════════════════ private static double SafeDiv(double n, double d) => d > 0 ? n / d : 0; private static string GetStrengthLabel(string channel, string objective) => channel switch { "google_ads" => objective switch { "awareness" => "Strong for search visibility", "traffic" => "Strong for search intent clicks", "leads" => "Strong for high-intent leads", "sales" => "Strong for purchase intent", _ => "Search & intent targeting" }, "meta" => objective switch { "awareness" => "Strong for reach & discovery", "traffic" => "Strong for social traffic", "leads" => "Strong for lead gen forms", "sales" => "Strong for retargeting & social proof", _ => "Social reach & engagement" }, "tiktok" => "Video-first engagement", _ => "Advertising channel" }; private static DistributionRecommendation BuildRecommendation( List channels, string objective) { if (channels.Count < 2) return new DistributionRecommendation { Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.", Highlights = new List() }; var top = channels[0]; var second = channels[1]; var highlights = new List(); if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0) { var ratio = top.Estimates.Clicks / second.Estimates.Clicks; if (ratio > 1.3) highlights.Add($"{DisplayName(top.Provider)}: ~{ratio:F0}x more clicks per dollar"); } if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0) { var ratio = second.Estimates.Impressions / top.Estimates.Impressions; if (ratio > 1.5) highlights.Add($"{DisplayName(second.Provider)}: ~{ratio:F0}x more impressions per dollar"); } if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0) { highlights.Add( $"CPA range: ${Math.Min(top.Estimates.EstimatedCpa!.Value, second.Estimates.EstimatedCpa!.Value):F0}" + $"–${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels"); } return new DistributionRecommendation { Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " + $"between {DisplayName(top.Provider)} and {DisplayName(second.Provider)}, " + $"optimized for {objective}.", Highlights = highlights }; } private static string DisplayName(string p) => p switch { "google_ads" => "Google", "meta" => "Meta", "tiktok" => "TikTok", _ => p }; // ── Internal result from a single provider ── private sealed class ProviderResult { public string Provider { get; set; } = string.Empty; public double Impressions { get; set; } public double? Reach { get; set; } public double Clicks { get; set; } public double Conversions { get; set; } public decimal AvgCpc { get; set; } public decimal AvgCpm { get; set; } public double Ctr { get; set; } public decimal? EstimatedCpa { get; set; } public string Confidence { get; set; } = "none"; public string DataSource { get; set; } = "none"; } } // ════════════════════════════════════════════════ // Objective-weighted scoring (copied from Gateway ForecastModels) // ════════════════════════════════════════════════ public sealed class MetricWeights { public double Reach { get; } public double Impressions { get; } public double Cpm { get; } public double Clicks { get; } public double Cpc { get; } public double Ctr { get; } public double Conversions { get; } public double Cpa { get; } public MetricWeights(double reach, double impressions, double cpm, double clicks, double cpc, double ctr, double conversions, double cpa) { Reach = reach; Impressions = impressions; Cpm = cpm; Clicks = clicks; Cpc = cpc; Ctr = ctr; Conversions = conversions; Cpa = cpa; } } public static class ObjectiveWeights { private static readonly Dictionary _weights = new(StringComparer.OrdinalIgnoreCase) { // reach imp cpm clicks cpc ctr conv cpa ["awareness"] = new MetricWeights(0.35, 0.25, 0.20, 0.05, 0.05, 0.05, 0.00, 0.00), ["traffic"] = new MetricWeights(0.05, 0.10, 0.10, 0.30, 0.30, 0.15, 0.00, 0.00), ["leads"] = new MetricWeights(0.05, 0.05, 0.05, 0.15, 0.15, 0.10, 0.25, 0.20), ["sales"] = new MetricWeights(0.05, 0.05, 0.05, 0.10, 0.10, 0.10, 0.30, 0.25), }; private static readonly MetricWeights _default = new(0.10, 0.10, 0.10, 0.20, 0.20, 0.10, 0.10, 0.10); public static MetricWeights For(string objective) => _weights.TryGetValue(objective, out var w) ? w : _default; }