runeforge 25.8.0

Blueprint to optimal stack JSON converter - Part of Rune* Ecosystem
use anyhow::{anyhow, Result};
use rand::{rngs::StdRng, seq::SliceRandom, SeedableRng};
use serde::Deserialize;

use crate::schema::{Blueprint, Stack, Decision, StackComponent, Estimated, SLO, P95MS, Risk};

#[derive(Debug, Clone, Deserialize)]
pub struct Rules {
    pub candidates: CandidateRules,
    pub weights: ScoreWeights,
    pub tie_breakers: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct CandidateRules {
    pub languages: Vec<Candidate>,
    pub frontends: Vec<Candidate>,
    pub backends: Vec<Candidate>,
    pub databases: Vec<Candidate>,
    pub caches: Vec<Candidate>,
    pub queues: Vec<Candidate>,
    pub auth: Vec<Candidate>,
    pub payments: Vec<Candidate>,
    pub ai: Vec<Candidate>,
    pub infra: Vec<Candidate>,
    pub ci_cd: Vec<Candidate>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct Candidate {
    pub name: String,
    pub quality: f64,
    pub cost_monthly_usd: f64,
    pub slo_read_ms: f64,
    pub slo_write_ms: f64,
    pub security_score: f64,
    pub ops_complexity: f64,
    pub coldstart_ms: Option<f64>,
    pub compliance: Vec<String>,
    pub regions: Vec<String>,
    pub tie_breaker: Option<u32>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ScoreWeights {
    pub quality: f64,
    pub slo: f64,
    pub cost: f64,
    pub security: f64,
    pub ops: f64,
}

pub struct StackSelector {
    rng: StdRng,
    rules: Rules,
}

impl StackSelector {
    pub fn new(seed: u64) -> Self {
        let rules_content = include_str!("../resources/rules.yaml");
        let rules: Rules = serde_yaml::from_str(rules_content)
            .expect("Failed to parse rules.yaml");
        
        Self {
            rng: StdRng::seed_from_u64(seed),
            rules,
        }
    }
    
    pub fn select_stack(&mut self, blueprint: &Blueprint) -> Result<Stack> {
        // Normalize blueprint
        let normalized = self.normalize_blueprint(blueprint);
        
        // Clone candidates to avoid borrowing issues
        let languages = self.rules.candidates.languages.clone();
        let backends = self.rules.candidates.backends.clone();
        let frontends = self.rules.candidates.frontends.clone();
        let databases = self.rules.candidates.databases.clone();
        let caches = self.rules.candidates.caches.clone();
        let queues = self.rules.candidates.queues.clone();
        let auths = self.rules.candidates.auth.clone();
        let payments = self.rules.candidates.payments.clone();
        let ais = self.rules.candidates.ai.clone();
        let infras = self.rules.candidates.infra.clone();
        let ci_cds = self.rules.candidates.ci_cd.clone();
        
        // Generate filtered candidates for each component
        let language_candidates = self.filter_candidates(&languages, &normalized)?;
        let backend_candidates = self.filter_candidates(&backends, &normalized)?;
        let frontend_candidates = self.filter_candidates(&frontends, &normalized)?;
        let database_candidates = self.filter_candidates(&databases, &normalized)?;
        let cache_candidates = self.filter_candidates(&caches, &normalized)?;
        let queue_candidates = self.filter_candidates(&queues, &normalized)?;
        let auth_candidates = self.filter_candidates(&auths, &normalized)?;
        let payments_candidates = self.filter_candidates(&payments, &normalized)?;
        let ai_candidates = self.filter_candidates(&ais, &normalized)?;
        let infra_candidates = self.filter_candidates(&infras, &normalized)?;
        let ci_cd_candidates = self.filter_candidates(&ci_cds, &normalized)?;
        
        // Score and select best candidates
        let language_choice = self.select_best_candidate(&language_candidates, &normalized)?.clone();
        let backend_choice = self.select_best_candidate(&backend_candidates, &normalized)?.clone();
        let frontend_choice = self.select_best_candidate(&frontend_candidates, &normalized)?.clone();
        let database_choice = self.select_best_candidate(&database_candidates, &normalized)?.clone();
        let cache_choice = self.select_best_candidate(&cache_candidates, &normalized)?.clone();
        let queue_choice = self.select_best_candidate(&queue_candidates, &normalized)?.clone();
        let auth_choice = self.select_best_candidate(&auth_candidates, &normalized)?.clone();
        let payments_choice = self.select_best_candidate(&payments_candidates, &normalized)?.clone();
        let ai_choice = self.select_best_candidate(&ai_candidates, &normalized)?.clone();
        let infra_choice = self.select_best_candidate(&infra_candidates, &normalized)?.clone();
        let ci_cd_choice = self.select_best_candidate(&ci_cd_candidates, &normalized)?.clone();
        
        // Generate decisions with reasons and alternatives
        let mut decisions = vec![];
        decisions.push(Self::create_decision("language", &language_choice, &language_candidates));
        decisions.push(Self::create_decision("backend", &backend_choice, &backend_candidates));
        decisions.push(Self::create_decision("frontend", &frontend_choice, &frontend_candidates));
        decisions.push(Self::create_decision("database", &database_choice, &database_candidates));
        decisions.push(Self::create_decision("cache", &cache_choice, &cache_candidates));
        decisions.push(Self::create_decision("queue", &queue_choice, &queue_candidates));
        decisions.push(Self::create_decision("auth", &auth_choice, &auth_candidates));
        decisions.push(Self::create_decision("payments", &payments_choice, &payments_candidates));
        decisions.push(Self::create_decision("ai", &ai_choice, &ai_candidates));
        decisions.push(Self::create_decision("infra", &infra_choice, &infra_candidates));
        decisions.push(Self::create_decision("ci_cd", &ci_cd_choice, &ci_cd_candidates));
        
        // Calculate estimated costs and SLO
        let total_cost = language_choice.cost_monthly_usd + backend_choice.cost_monthly_usd + 
                        frontend_choice.cost_monthly_usd + database_choice.cost_monthly_usd +
                        cache_choice.cost_monthly_usd + queue_choice.cost_monthly_usd +
                        auth_choice.cost_monthly_usd + payments_choice.cost_monthly_usd +
                        ai_choice.cost_monthly_usd + infra_choice.cost_monthly_usd +
                        ci_cd_choice.cost_monthly_usd;
        
        let avg_read_ms = [&backend_choice, &database_choice, &cache_choice]
            .iter().map(|c| c.slo_read_ms).sum::<f64>() / 3.0;
        let avg_write_ms = [&backend_choice, &database_choice, &queue_choice]
            .iter().map(|c| c.slo_write_ms).sum::<f64>() / 3.0;
        
        let estimated = Estimated {
            monthly_cost_usd: total_cost,
        };
        
        let slo = SLO {
            availability_pct: 99.95,
            p95_ms: P95MS {
                read: avg_read_ms,
                write: avg_write_ms,
            },
        };
        
        // Create stack component
        let stack = StackComponent {
            language: language_choice.name.clone(),
            frontend: Some(frontend_choice.name.clone()),
            backend: backend_choice.name.clone(),
            database: Some(database_choice.name.clone()),
            cache: Some(cache_choice.name.clone()),
            queue: Some(queue_choice.name.clone()),
            auth: Some(auth_choice.name.clone()),
            payments: Some(payments_choice.name.clone()),
            ai: Some(vec![ai_choice.name.clone()]),
            infra: infra_choice.name.clone(),
            ci_cd: Some(ci_cd_choice.name.clone()),
        };
        
        // Generate risks
        let mut risks = vec![];
        if infra_choice.name.contains("Workers") {
            risks.push(Risk {
                id: "r1".to_string(),
                desc: "Workers で TCP 制限".to_string(),
                mitigation: "`connect()` API 経由で HTTP 化".to_string(),
            });
        }
        
        Ok(Stack {
            decisions,
            stack,
            estimated,
            slo,
            risk: risks,
        })
    }
    
    fn normalize_blueprint(&self, blueprint: &Blueprint) -> Blueprint {
        let mut normalized = blueprint.clone();
        
        // Set defaults for missing optional fields
        if normalized.constraints.is_none() {
            normalized.constraints = Some(Default::default());
        }
        
        if normalized.prefs.is_none() {
            normalized.prefs = Some(Default::default());
        }
        
        normalized
    }
    
    fn filter_candidates<'a>(&self, candidates: &'a [Candidate], blueprint: &Blueprint) -> Result<Vec<&'a Candidate>> {
        let mut filtered = vec![];
        
        for candidate in candidates {
            // Filter by cost constraint
            if let Some(constraints) = &blueprint.constraints {
                if let Some(max_cost) = constraints.monthly_cost_usd_max {
                    if candidate.cost_monthly_usd > max_cost {
                        continue;
                    }
                }
                
                // Filter by coldstart constraint
                if let Some(max_coldstart) = constraints.coldstart_ms_max {
                    if let Some(coldstart) = candidate.coldstart_ms {
                        if coldstart > max_coldstart {
                            continue;
                        }
                    }
                }
                
                // Filter by compliance requirements
                if !constraints.compliance.is_empty() {
                    let has_all_compliance = constraints.compliance
                        .iter()
                        .all(|req| candidate.compliance.contains(req));
                    if !has_all_compliance {
                        continue;
                    }
                }
                
                // Filter by region requirements
                if !constraints.region_allow.is_empty() {
                    let has_allowed_region = constraints.region_allow
                        .iter()
                        .any(|region| candidate.regions.contains(region));
                    if !has_allowed_region {
                        continue;
                    }
                }
            }
            
            filtered.push(candidate);
        }
        
        if filtered.is_empty() {
            return Err(anyhow!("No candidates match the constraints"));
        }
        
        Ok(filtered)
    }
    
    fn select_best_candidate<'a>(&mut self, candidates: &[&'a Candidate], blueprint: &Blueprint) -> Result<&'a Candidate> {
        if candidates.is_empty() {
            return Err(anyhow!("No candidates available"));
        }
        
        let mut scored_candidates: Vec<(f64, &Candidate)> = candidates
            .iter()
            .map(|candidate| {
                let score = self.calculate_score(candidate, blueprint);
                (score, *candidate)
            })
            .collect();
        
        // Sort by score (descending)
        scored_candidates.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
        
        // Handle ties with tie breaker or random selection
        let best_score = scored_candidates[0].0;
        let tied_candidates: Vec<&Candidate> = scored_candidates
            .iter()
            .take_while(|(score, _)| (*score - best_score).abs() < f64::EPSILON)
            .map(|(_, candidate)| *candidate)
            .collect();
        
        if tied_candidates.len() == 1 {
            Ok(tied_candidates[0])
        } else {
            // Use tie breaker or random selection
            let chosen = tied_candidates.choose(&mut self.rng).unwrap();
            Ok(chosen)
        }
    }
    
    fn calculate_score(&self, candidate: &Candidate, _blueprint: &Blueprint) -> f64 {
        let weights = &self.rules.weights;
        
        // Normalize metrics (0-1 scale)
        let quality_score = candidate.quality / 10.0; // Assuming max quality is 10
        let slo_score = 1.0 / (1.0 + (candidate.slo_read_ms + candidate.slo_write_ms) / 2000.0);
        let cost_score = 1.0 / (1.0 + candidate.cost_monthly_usd / 1000.0);
        let security_score = candidate.security_score / 10.0; // Assuming max security is 10
        let ops_score = 1.0 / (1.0 + candidate.ops_complexity / 10.0); // Assuming max complexity is 10
        
        weights.quality * quality_score +
        weights.slo * slo_score +
        weights.cost * cost_score +
        weights.security * security_score +
        weights.ops * ops_score
    }
    
    fn create_decision(topic: &str, chosen: &Candidate, candidates: &[&Candidate]) -> Decision {
        let alternatives: Vec<String> = candidates
            .iter()
            .filter(|c| c.name != chosen.name)
            .take(2) // Limit to top 2 alternatives
            .map(|c| c.name.clone())
            .collect();
        
        let reasons = match topic {
            "backend" => vec!["最高 RPS".to_string(), "Tokio 安定".to_string()],
            "database" => vec!["低レイテンシ".to_string(), "高可用性".to_string()],
            "cache" => vec!["Edge配置".to_string(), "低コスト".to_string()],
            _ => vec!["最適スコア".to_string()],
        };
        
        Decision {
            topic: topic.to_string(),
            choice: chosen.name.clone(),
            reasons,
            alternatives,
        }
    }
}