package com.leanowtech.bloge.book.practices;

import com.leanowtech.bloge.core.catalog.CapabilityEffect;
import com.leanowtech.bloge.core.context.GraphContext;
import com.leanowtech.bloge.core.effect.EffectDescriptor;
import com.leanowtech.bloge.core.effect.EffectPort;
import com.leanowtech.bloge.core.engine.GraphEngine;
import com.leanowtech.bloge.core.engine.GraphResult;
import com.leanowtech.bloge.core.model.NodeStatus;
import com.leanowtech.bloge.core.operator.Idempotency;
import com.leanowtech.bloge.core.operator.Operator;
import com.leanowtech.bloge.core.spi.DefaultExpressionFunctionRegistryFactory;
import com.leanowtech.bloge.core.spi.DefaultOperatorRegistry;
import com.leanowtech.bloge.core.spi.JsonCodec;
import com.leanowtech.bloge.core.spi.SecretProvider;
import com.leanowtech.bloge.dsl.compiler.GraphLoader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import static org.junit.jupiter.api.Assertions.*;

class CodingPracticesTest {
    static String resource(String name) throws IOException {
        try (var input = CodingPracticesTest.class.getResourceAsStream("/" + name)) {
            if (input == null) throw new IOException("Missing workshop asset: " + name);
            return new String(input.readAllBytes(), StandardCharsets.UTF_8);
        }
    }

    static Stream<Arguments> approvedExamples() throws IOException {
        return resource("oracles.csv").lines().skip(1).map(line -> {
            String[] values = line.split(",");
            return Arguments.of(values[0], Map.<String, Object>of(
                "applicationId", "LA-PRACTICE", "applicantName", "Lee",
                "requestedAmount", 100000, "fraudRisk", "low",
                "creditScore", Integer.parseInt(values[1]),
                "fraudScore", Integer.parseInt(values[2]),
                "blacklistHit", Boolean.parseBoolean(values[3]),
                "incomeStatus", values[4]), values[5]);
        });
    }

    // Fixed starter policy; the CSV, rather than this implementation, supplies expected results.
    static String legacyDecision(Map<String, Object> input) {
        int credit = ((Number) input.get("creditScore")).intValue();
        int fraud = ((Number) input.get("fraudScore")).intValue();
        String income = (String) input.get("incomeStatus");
        if (Boolean.TRUE.equals(input.get("blacklistHit")) || credit < 600
                || fraud >= 80 || "weak".equals(income)) return "auto_reject";
        if (credit >= 740 && fraud < 20 && "strong".equals(income)) return "auto_approve";
        return "manual_review";
    }

    static Map<String, Object> riskDetails(Map<String, Object> input) {
        String decision = (String) input.get("decision");
        List<String> reasons = new ArrayList<>();
        if (Boolean.TRUE.equals(input.get("blacklistHit"))) reasons.add("Applicant matched a block list.");
        if (((Number) input.get("creditScore")).intValue() < 650) reasons.add("Credit score requires caution.");
        if (((Number) input.get("fraudScore")).intValue() >= 40) reasons.add("Fraud risk is not low.");
        if (!"strong".equals(input.get("incomeStatus"))) reasons.add("Income coverage requires review.");
        if (reasons.isEmpty()) reasons.add("All automated checks are within approval thresholds.");
        int priority = "manual_review".equals(decision) ? 4 : "auto_reject".equals(decision) ? 5 : 2;
        return Map.of("applicationId", input.get("applicationId"), "decision", decision,
            "priority", priority, "reasons", List.copyOf(reasons),
            "taskTitle", "Review loan application " + input.get("applicationId"),
            "taskDescription", "Manual review required for " + input.get("applicantName"),
            "reviewFormData", Map.of("applicationId", input.get("applicationId"),
                "requestedAmount", input.get("requestedAmount"), "applicantName", input.get("applicantName"),
                "creditScore", input.get("creditScore"), "fraudRisk", input.get("fraudRisk"),
                "incomeStatus", input.get("incomeStatus"), "blacklistHit", input.get("blacklistHit"),
                "recommendation", decision));
    }

    record Run(GraphResult result, int approvalActions, int rejectionActions, int reviewActions) {}

    static Run run(String source, Map<String, Object> facts) {
        var operators = new DefaultOperatorRegistry();
        AtomicInteger approvals = new AtomicInteger();
        AtomicInteger rejections = new AtomicInteger();
        AtomicInteger reviews = new AtomicInteger();
        Operator<Map<String, Object>, Map<String, Object>> legacy = (input, ctx) -> Map.of("decision", legacyDecision(input));
        Operator<Map<String, Object>, Map<String, Object>> details = (input, ctx) -> riskDetails(input);
        Operator<Object, String> approve = (input, ctx) -> { approvals.incrementAndGet(); return "approved"; };
        operators.register("LegacyDecision", legacy);
        operators.register("RiskDetails", details);
        operators.register("Approve", approve);
        operators.register("Reject", (Operator<Object, String>) (input, ctx) -> {
            rejections.incrementAndGet(); return "rejected";
        });
        operators.register("Review", (Operator<Object, String>) (input, ctx) -> {
            reviews.incrementAndGet(); return "manual_review";
        });
        var graph = new GraphLoader(operators).load(source);
        var engine = GraphEngine.builder().registry(operators).build();
        try {
            var result = engine.execute(graph, new GraphContext(facts));
            return new Run(result, approvals.get(), rejections.get(), reviews.get());
        } finally { engine.shutdownGracefully(Duration.ofSeconds(2)); }
    }

    @ParameterizedTest(name = "{0}")
    @MethodSource("approvedExamples")
    void preservesDecisionMetadataAndUnselectedEffects(String caseId, Map<String, Object> facts,
                                                       String expected) throws Exception {
        Run before = run(resource("loan-before.bloge"), facts);
        Run after = run(resource("loan-after.bloge"), facts);
        before.result().requireSuccess();
        after.result().requireSuccess();
        Map<?, ?> original = before.result().getOutput("risk", Map.class);
        Map<?, ?> refactored = after.result().getOutput("risk", Map.class);
        assertEquals(expected, refactored.get("decision"), caseId);
        assertEquals(original, refactored, "metadata must survive the decision refactor");
        assertEquals(7, refactored.size());
        assertEquals(Map.of("applicationId", "LA-PRACTICE", "requestedAmount", 100000,
            "applicantName", "Lee", "creditScore", facts.get("creditScore"), "fraudRisk", "low",
            "incomeStatus", facts.get("incomeStatus"), "blacklistHit", facts.get("blacklistHit"),
            "recommendation", expected), refactored.get("reviewFormData"), "independent form contract");
        String selected = switch (expected) {
            case "auto_approve" -> "approve";
            case "auto_reject" -> "reject";
            default -> "manualReview";
        };
        String routeResult = switch (expected) {
            case "auto_approve" -> "approved";
            case "auto_reject" -> "rejected";
            default -> "manual_review";
        };
        for (Run run : List.of(before, after)) {
            for (String node : List.of("approve", "reject", "manualReview")) {
                assertEquals(node.equals(selected) ? NodeStatus.COMPLETED : NodeStatus.SKIPPED,
                    run.result().statusMap().get(node));
            }
            assertEquals(routeResult, run.result().getOutput(selected, String.class));
            assertEquals(expected.equals("auto_approve") ? 1 : 0, run.approvalActions());
            assertEquals(expected.equals("auto_reject") ? 1 : 0, run.rejectionActions());
            assertEquals(expected.equals("manual_review") ? 1 : 0, run.reviewActions());
        }
    }

    @Test
    void oracleDetectsAnApprovalRuleMovedAheadOfBlacklist() throws Exception {
        String source = resource("loan-after.bloge");
        String approval = "    rule (score: score >= 740, fraudScore: fraudScore < 20,\n"
            + "          incomeStatus: incomeStatus == \"strong\") -> \"auto_approve\"\n";
        assertTrue(source.contains(approval), "mutation must change the intended rule");
        String mutant = source.replace(approval, "").replace(
            "    rule (blacklistHit:", approval + "    rule (blacklistHit:");
        Map<String, Object> facts = Map.of("applicationId", "LA-BLOCKED", "applicantName", "Lee",
            "requestedAmount", 100000, "fraudRisk", "low",
            "creditScore", 760, "fraudScore", 10, "blacklistHit", true, "incomeStatus", "strong");
        Run original = run(source, facts);
        Run changed = run(mutant, facts);
        original.result().requireSuccess();
        changed.result().requireSuccess();
        assertEquals("auto_reject", original.result().getOutput("risk", Map.class).get("decision"));
        assertEquals("auto_approve", changed.result().getOutput("risk", Map.class).get("decision"));
        assertEquals(1, changed.approvalActions(), "the mutation leaks the forbidden action");
    }

    @Test
    void uniqueIsNotAnEquivalentReplacementForBusinessPriority() throws Exception {
        String source = resource("loan-after.bloge").replace("hit=first", "hit=unique");
        Run result = run(source, Map.of("applicationId", "LA-BLOCKED", "applicantName", "Lee",
            "requestedAmount", 100000, "fraudRisk", "low",
            "creditScore", 760, "fraudScore", 10, "blacklistHit", true, "incomeStatus", "strong"));
        assertFalse(result.result().isSuccess());
        assertTrue(result.result().errors().toString().contains("Multiple rules matched"));
        assertEquals(0, result.approvalActions());
    }

    @Test
    void replacingTheOracleCanHideARegressionButTheFrozenAnswerRejectsIt() throws Exception {
        String source = resource("loan-after.bloge");
        String approval = "    rule (score: score >= 740, fraudScore: fraudScore < 20,\n"
            + "          incomeStatus: incomeStatus == \"strong\") -> \"auto_approve\"\n";
        assertTrue(source.contains(approval));
        String mutant = source.replace(approval, "").replace("    rule (blacklistHit:", approval + "    rule (blacklistHit:");
        var facts = Map.<String, Object>of("applicationId", "LA-PRACTICE", "applicantName", "Lee",
            "requestedAmount", 100000, "fraudRisk", "low", "creditScore", 760,
            "fraudScore", 10, "blacklistHit", true, "incomeStatus", "strong");
        Run changed = run(mutant, facts);
        changed.result().requireSuccess();
        String observed = (String) changed.result().getOutput("risk", Map.class).get("decision");
        String fixedLine = resource("oracles.csv").lines().filter(line -> line.startsWith("blacklist-priority,")).findFirst().orElseThrow();
        String fixedAnswer = fixedLine.split(",")[5];
        String forgedLine = fixedLine.replace("auto_reject", observed);
        assertEquals(observed, forgedLine.split(",")[5], "a candidate-derived Oracle gives a false green");
        assertThrows(AssertionError.class, () -> assertEquals(fixedAnswer, observed),
            "the unchanged business-approved answer detects the regression");
        assertEquals(1, changed.approvalActions());
    }

    @Test
    void collectOtherwiseIsAlwaysAppended() {
        var registry = new DefaultOperatorRegistry();
        var graph = new GraphLoader(registry).load("""
            graph reasons {
              decision_table reason(score = ctx.score) hit=collect -> String {
                rule (score: score >= 700) -> "high"
                otherwise -> "normal"
              }
            }
            """);
        var engine = GraphEngine.builder().registry(registry).build();
        try {
            var output = engine.execute(graph, new GraphContext(Map.of("score", 760)))
                .requireSuccess().getOutput("reason", Map.class);
            assertEquals(List.of("high", "normal"), output.get("items"));
        } finally { engine.shutdownGracefully(Duration.ofSeconds(2)); }
    }

    @Test
    void typedFunctionIsRegisteredAndProjectionKeepsOneBusinessInvocation() throws Exception {
        var functions = DefaultExpressionFunctionRegistryFactory.create(SecretProvider.NONE, JsonCodec.DEFAULT);
        functions.registerTypedStatic(LoanTextFunctions.class);
        Locale previous = Locale.getDefault();
        try {
            Locale.setDefault(Locale.forLanguageTag("tr-TR"));
            assertEquals("POLICY-I", functions.lookup("loanText.normalizePolicyCode").apply(" policy-i "));
        } finally { Locale.setDefault(previous); }
        assertThrows(IllegalArgumentException.class, () -> LoanTextFunctions.normalizePolicyCode(" "));
        assertThrows(IllegalArgumentException.class, () -> LoanTextFunctions.normalizePolicyCode(null));
        assertTrue(functions.descriptors().stream().anyMatch(f -> f.name().equals("loanText.normalizePolicyCode")));
        var registry = new DefaultOperatorRegistry();
        AtomicInteger observations = new AtomicInteger();
        registry.register("Observe", (Operator<Map<String, Object>, Map<String, Object>>) (input, ctx) -> {
            observations.incrementAndGet(); return input;
        });
        var graph = new GraphLoader(registry, SecretProvider.NONE, JsonCodec.DEFAULT, functions)
            .load(resource("function-projection.bloge"));
        var engine = GraphEngine.builder().registry(registry).build();
        try {
            var output = engine.execute(graph, new GraphContext(Map.of("policyCode", " policy-i ",
                "displayName", " Lee "))).requireSuccess().getOutput("observe", Map.class);
            assertEquals(Map.of("policyCode", "POLICY-I", "displayName", "Lee"), output);
            assertEquals(1, observations.get());
        } finally { engine.shutdownGracefully(Duration.ofSeconds(2)); }
    }

    @Test
    void effectKeyDeclarationDoesNotDeduplicateProductionCalls() throws Exception {
        AtomicInteger calls = new AtomicInteger();
        EffectPort<String, String> port = new EffectPort<>() {
            public EffectDescriptor descriptor() {
                return new EffectDescriptor("workshop.charge", CapabilityEffect.WRITE_EXTERNAL,
                    Idempotency.IDEMPOTENT, "string-v1", "string-v1");
            }
            public String idempotencyKey(String input) { return input; }
            public String executeReal(String input) { calls.incrementAndGet(); return input; }
        };
        port.invoke("order-42:payment-1");
        port.invoke("order-42:payment-1");
        assertEquals(2, calls.get(), "the provider/application owns deduplication");
    }
}
