ArchitectureGraphQueryServiceImpl.java

package com.taxonomy.architecture.service;

import com.taxonomy.dto.*;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.model.TaxonomyRelation;
import com.taxonomy.model.RelationType;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.catalog.repository.TaxonomyRelationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.stream.Collectors;
import com.taxonomy.catalog.service.TaxonomyRelationService;
import com.taxonomy.dto.ChangeImpactView;
import com.taxonomy.dto.GraphNeighborhoodView;
import com.taxonomy.dto.ImpactElement;
import com.taxonomy.dto.ImpactRelationship;
import com.taxonomy.dto.RequirementImpactView;
import com.taxonomy.dto.TaxonomyRelationDto;
import com.taxonomy.pipeline.PipelineConstants;

/**
 * Implementation of {@link ArchitectureGraphQueryService}.
 *
 * <p>Traverses taxonomy relations using BFS with relevance decay per hop.
 * Only whitelisted relation types (SUPPORTS, REALIZES, USES) are traversed.
 */
@Service
public class ArchitectureGraphQueryServiceImpl implements ArchitectureGraphQueryService {

    private static final Logger log = LoggerFactory.getLogger(ArchitectureGraphQueryServiceImpl.class);

    /** Maximum allowed hops (clamped). */
    private static final int MAX_HOP_LIMIT = 5;

    /** Minimum relevance threshold — anything below is discarded. */
    static final double MIN_RELEVANCE = 0.20;

    /** Per-hop decay factor. */
    static final double HOP_DECAY = 0.70;

    // Anchor thresholds from the shared pipeline constants.
    private static final int ANCHOR_THRESHOLD_HIGH = PipelineConstants.ANCHOR_THRESHOLD_HIGH;
    private static final int ANCHOR_THRESHOLD_LOW  = PipelineConstants.ANCHOR_THRESHOLD_LOW;
    private static final int MIN_ANCHORS           = PipelineConstants.MIN_ANCHORS;

    /**
     * Propagation weights per relation type.
     * SUPPORTS propagates strongly (service-to-process),
     * REALIZES medium (capability-to-service),
     * USES weaker (app-to-service).
     */
    static final Map<String, Double> TYPE_WEIGHTS = Map.of(
            "SUPPORTS", 0.80,
            "REALIZES", 0.75,
            "USES", 0.60
    );

    /** Whitelisted relation types for traversal. */
    private static final List<RelationType> WHITELISTED_TYPES = List.of(
            RelationType.SUPPORTS,
            RelationType.REALIZES,
            RelationType.USES
    );

    private final TaxonomyNodeRepository nodeRepository;
    private final TaxonomyRelationRepository relationRepository;
    private final TaxonomyRelationService relationService;

    public ArchitectureGraphQueryServiceImpl(TaxonomyNodeRepository nodeRepository,
                                              TaxonomyRelationRepository relationRepository,
                                              TaxonomyRelationService relationService) {
        this.nodeRepository = nodeRepository;
        this.relationRepository = relationRepository;
        this.relationService = relationService;
    }

    // ── 1. Requirement Impact ──────────────────────────────────────────────

    @Override
    @Transactional(readOnly = true)
    public RequirementImpactView findImpactForRequirement(Map<String, Integer> scores,
                                                           String businessText, int maxHops) {
        RequirementImpactView view = new RequirementImpactView();
        view.setBusinessText(businessText);
        int clampedHops = clampHops(maxHops);
        view.setMaxHops(clampedHops);

        if (scores == null || scores.isEmpty()) {
            view.getNotes().add("No scores provided; impact analysis cannot be performed.");
            return view;
        }

        // Select anchors using the same logic as RequirementArchitectureViewService
        List<Map.Entry<String, Integer>> anchors = selectAnchors(scores);
        if (anchors.isEmpty()) {
            view.getNotes().add("No nodes met the anchor threshold; impact view is empty.");
            return view;
        }

        // Build anchor relevance map
        Map<String, Double> anchorRelevances = new LinkedHashMap<>();
        for (Map.Entry<String, Integer> entry : anchors) {
            anchorRelevances.put(entry.getKey(), entry.getValue() / 100.0);
        }

        // BFS traversal from anchors
        TraversalResult traversal = bfsTraversal(anchorRelevances, clampedHops, Direction.BOTH);

        // Convert to view elements
        view.setImpactedElements(toImpactElements(traversal));
        view.setTraversedRelationships(traversal.relationships);
        view.setTotalElements(view.getImpactedElements().size());
        view.setTotalRelationships(view.getTraversedRelationships().size());

        if (view.getImpactedElements().isEmpty()) {
            view.getNotes().add("Anchors were found but no connected elements via accepted relations.");
        }

        log.info("Requirement impact: {} anchors, {} elements, {} relationships (maxHops={})",
                anchors.size(), view.getTotalElements(), view.getTotalRelationships(), clampedHops);

        return view;
    }

    // ── 2. Upstream / Downstream ───────────────────────────────────────────

    @Override
    @Transactional(readOnly = true)
    public GraphNeighborhoodView findUpstream(String nodeCode, int maxHops) {
        return findNeighborhood(nodeCode, maxHops, Direction.UPSTREAM);
    }

    @Override
    @Transactional(readOnly = true)
    public GraphNeighborhoodView findDownstream(String nodeCode, int maxHops) {
        return findNeighborhood(nodeCode, maxHops, Direction.DOWNSTREAM);
    }

    private GraphNeighborhoodView findNeighborhood(String nodeCode, int maxHops, Direction direction) {
        GraphNeighborhoodView view = new GraphNeighborhoodView();
        view.setOriginNodeCode(nodeCode);
        view.setDirection(direction.name());
        int clampedHops = clampHops(maxHops);
        view.setMaxHops(clampedHops);

        Optional<TaxonomyNode> originOpt = nodeRepository.findByCode(nodeCode);
        if (originOpt.isEmpty()) {
            view.getNotes().add("Node not found: " + nodeCode);
            return view;
        }

        Map<String, Double> startMap = Map.of(nodeCode, 1.0);
        TraversalResult traversal = bfsTraversal(startMap, clampedHops, direction);

        // Remove the origin node from results
        List<ImpactElement> neighbors = toImpactElements(traversal).stream()
                .filter(e -> !e.getNodeCode().equals(nodeCode))
                .collect(Collectors.toList());

        view.setNeighbors(neighbors);
        view.setTraversedRelationships(traversal.relationships);
        view.setTotalNeighbors(neighbors.size());
        view.setTotalRelationships(traversal.relationships.size());

        if (neighbors.isEmpty()) {
            view.getNotes().add("No " + direction.name().toLowerCase() + " neighbors found for " + nodeCode);
        }

        log.info("{} query for {}: {} neighbors, {} relationships (maxHops={})",
                direction, nodeCode, neighbors.size(), traversal.relationships.size(), clampedHops);

        return view;
    }

    // ── 3. Failure / Change Impact ─────────────────────────────────────────

    @Override
    @Transactional(readOnly = true)
    public ChangeImpactView findFailureImpact(String nodeCode, int maxHops) {
        ChangeImpactView view = new ChangeImpactView();
        view.setFailedNodeCode(nodeCode);
        int clampedHops = clampHops(maxHops);
        view.setMaxHops(clampedHops);

        Optional<TaxonomyNode> nodeOpt = nodeRepository.findByCode(nodeCode);
        if (nodeOpt.isEmpty()) {
            view.getNotes().add("Node not found: " + nodeCode);
            return view;
        }

        // Failure propagates through both directions (anything connected is at risk)
        Map<String, Double> startMap = Map.of(nodeCode, 1.0);
        TraversalResult traversal = bfsTraversal(startMap, clampedHops, Direction.BOTH);

        List<ImpactElement> allAffected = toImpactElements(traversal).stream()
                .filter(e -> !e.getNodeCode().equals(nodeCode))
                .collect(Collectors.toList());

        // Split into directly affected (hop 1) and indirectly affected (hop 2+)
        List<ImpactElement> direct = allAffected.stream()
                .filter(e -> e.getHopDistance() == 1)
                .collect(Collectors.toList());
        List<ImpactElement> indirect = allAffected.stream()
                .filter(e -> e.getHopDistance() > 1)
                .collect(Collectors.toList());

        view.setDirectlyAffected(direct);
        view.setIndirectlyAffected(indirect);
        view.setTraversedRelationships(traversal.relationships);
        view.setTotalAffected(allAffected.size());
        view.setTotalRelationships(traversal.relationships.size());

        if (allAffected.isEmpty()) {
            view.getNotes().add("No connected elements found for " + nodeCode);
        }

        log.info("Failure impact for {}: {} direct, {} indirect, {} relationships (maxHops={})",
                nodeCode, direct.size(), indirect.size(), traversal.relationships.size(), clampedHops);

        return view;
    }

    // ── Internal Traversal Engine ──────────────────────────────────────────

    private enum Direction { UPSTREAM, DOWNSTREAM, BOTH }

    /**
     * Intermediate result from BFS traversal.
     */
    private static class TraversalResult {
        final Map<String, Double> relevanceMap;
        final Map<String, Integer> hopMap;
        final Map<String, String> reasonMap;
        final List<ImpactRelationship> relationships;

        TraversalResult(Map<String, Double> relevanceMap, Map<String, Integer> hopMap,
                        Map<String, String> reasonMap, List<ImpactRelationship> relationships) {
            this.relevanceMap = relevanceMap;
            this.hopMap = hopMap;
            this.reasonMap = reasonMap;
            this.relationships = relationships;
        }
    }

    /**
     * Generic BFS traversal with relevance propagation and direction control.
     */
    private TraversalResult bfsTraversal(Map<String, Double> startRelevances,
                                          int maxHops, Direction direction) {
        Map<String, Double> relevanceMap = new LinkedHashMap<>(startRelevances);
        Map<String, Integer> hopMap = new LinkedHashMap<>();
        Map<String, String> reasonMap = new LinkedHashMap<>();
        List<ImpactRelationship> relationships = new ArrayList<>();

        // Initialize start nodes
        for (Map.Entry<String, Double> entry : startRelevances.entrySet()) {
            hopMap.put(entry.getKey(), 0);
            reasonMap.put(entry.getKey(), "origin");
        }

        Set<String> currentFrontier = new LinkedHashSet<>(startRelevances.keySet());
        Set<Long> visitedRelationIds = new HashSet<>();

        for (int hop = 1; hop <= maxHops; hop++) {
            Set<String> nextFrontier = new LinkedHashSet<>();

            for (String nodeCode : currentFrontier) {
                double sourceRelevance = relevanceMap.getOrDefault(nodeCode, 0.0);
                if (sourceRelevance < MIN_RELEVANCE) continue;

                List<TaxonomyRelationDto> relations = getDirectedRelations(nodeCode, direction);

                for (TaxonomyRelationDto rel : relations) {
                    String targetCode = determineTarget(rel, nodeCode, direction);
                    if (targetCode == null) continue;

                    Double typeWeight = TYPE_WEIGHTS.get(rel.getRelationType());
                    if (typeWeight == null) continue;

                    double propagated = sourceRelevance * typeWeight;
                    if (hop > 1) {
                        propagated *= HOP_DECAY;
                    }

                    if (propagated < MIN_RELEVANCE) continue;

                    double existing = relevanceMap.getOrDefault(targetCode, 0.0);
                    if (propagated > existing) {
                        relevanceMap.put(targetCode, propagated);
                        hopMap.put(targetCode, hop);
                        reasonMap.put(targetCode,
                                "propagated via " + rel.getRelationType() + " from " + nodeCode);
                        nextFrontier.add(targetCode);
                    }

                    // Record traversed relationship (deduplicate by relation ID)
                    if (rel.getId() != null && visitedRelationIds.add(rel.getId())) {
                        ImpactRelationship ir = new ImpactRelationship();
                        ir.setRelationId(rel.getId());
                        ir.setSourceCode(rel.getSourceCode());
                        ir.setTargetCode(rel.getTargetCode());
                        ir.setRelationType(rel.getRelationType());
                        ir.setPropagatedRelevance(propagated);
                        ir.setHopDistance(hop);
                        relationships.add(ir);
                    }
                }
            }

            currentFrontier = nextFrontier;
            if (currentFrontier.isEmpty()) break;
        }

        return new TraversalResult(relevanceMap, hopMap, reasonMap, relationships);
    }

    /**
     * Gets relations for a node in the specified direction.
     */
    private List<TaxonomyRelationDto> getDirectedRelations(String nodeCode, Direction direction) {
        List<TaxonomyRelationDto> result = new ArrayList<>();

        if (direction == Direction.DOWNSTREAM || direction == Direction.BOTH) {
            // Outgoing: this node is the source
            List<TaxonomyRelation> outgoing =
                    relationRepository.findBySourceNodeCodeAndRelationTypeIn(nodeCode, WHITELISTED_TYPES);
            for (TaxonomyRelation r : outgoing) {
                result.add(relationService.toDto(r));
            }
        }

        if (direction == Direction.UPSTREAM || direction == Direction.BOTH) {
            // Incoming: this node is the target
            List<TaxonomyRelation> incoming =
                    relationRepository.findByTargetNodeCodeAndRelationTypeIn(nodeCode, WHITELISTED_TYPES);
            for (TaxonomyRelation r : incoming) {
                result.add(relationService.toDto(r));
            }
        }

        // Also add bidirectional relations traversed in the opposite direction
        if (direction == Direction.DOWNSTREAM || direction == Direction.BOTH) {
            List<TaxonomyRelation> incomingBidir =
                    relationRepository.findByTargetNodeCodeAndRelationTypeIn(nodeCode, WHITELISTED_TYPES);
            for (TaxonomyRelation r : incomingBidir) {
                if (r.isBidirectional()) {
                    result.add(relationService.toDto(r));
                }
            }
        }

        if (direction == Direction.UPSTREAM || direction == Direction.BOTH) {
            List<TaxonomyRelation> outgoingBidir =
                    relationRepository.findBySourceNodeCodeAndRelationTypeIn(nodeCode, WHITELISTED_TYPES);
            for (TaxonomyRelation r : outgoingBidir) {
                if (r.isBidirectional()) {
                    result.add(relationService.toDto(r));
                }
            }
        }

        return result;
    }

    /**
     * Determines the traversal target given a relation and direction.
     */
    private String determineTarget(TaxonomyRelationDto rel, String nodeCode, Direction direction) {
        switch (direction) {
            case DOWNSTREAM:
                // Follow outgoing edges: source → target
                if (rel.getSourceCode().equals(nodeCode)) {
                    return rel.getTargetCode();
                }
                // Bidirectional incoming can be traversed in reverse
                if (rel.getTargetCode().equals(nodeCode) && rel.isBidirectional()) {
                    return rel.getSourceCode();
                }
                return null;

            case UPSTREAM:
                // Follow incoming edges: target ← source
                if (rel.getTargetCode().equals(nodeCode)) {
                    return rel.getSourceCode();
                }
                // Bidirectional outgoing can be traversed in reverse
                if (rel.getSourceCode().equals(nodeCode) && rel.isBidirectional()) {
                    return rel.getTargetCode();
                }
                return null;

            case BOTH:
            default:
                if (rel.getSourceCode().equals(nodeCode)) {
                    return rel.getTargetCode();
                }
                if (rel.getTargetCode().equals(nodeCode)) {
                    return rel.getSourceCode();
                }
                return null;
        }
    }

    // ── Helpers ────────────────────────────────────────────────────────────

    private List<ImpactElement> toImpactElements(TraversalResult traversal) {
        List<ImpactElement> elements = new ArrayList<>();

        for (Map.Entry<String, Double> entry : traversal.relevanceMap.entrySet()) {
            String code = entry.getKey();
            ImpactElement el = new ImpactElement();
            el.setNodeCode(code);
            el.setRelevance(entry.getValue());
            el.setHopDistance(traversal.hopMap.getOrDefault(code, 0));
            el.setIncludedBecause(traversal.reasonMap.getOrDefault(code, "unknown"));

            // Lookup title and taxonomy sheet
            nodeRepository.findByCode(code).ifPresent(node -> {
                el.setTitle(node.getNameEn());
                el.setTaxonomySheet(node.getTaxonomyRoot());
            });

            elements.add(el);
        }

        // Sort by relevance descending
        elements.sort(Comparator.comparingDouble(ImpactElement::getRelevance).reversed());

        return elements;
    }

    private List<Map.Entry<String, Integer>> selectAnchors(Map<String, Integer> scores) {
        // Sort by score descending
        List<Map.Entry<String, Integer>> sorted = scores.entrySet().stream()
                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
                .collect(Collectors.toList());

        // Prefer all nodes above HIGH threshold
        List<Map.Entry<String, Integer>> highAnchors = sorted.stream()
                .filter(e -> e.getValue() >= ANCHOR_THRESHOLD_HIGH)
                .collect(Collectors.toList());

        if (highAnchors.size() >= MIN_ANCHORS) {
            return highAnchors;
        }

        // Fallback: top-N above LOW threshold
        List<Map.Entry<String, Integer>> lowAnchors = sorted.stream()
                .filter(e -> e.getValue() >= ANCHOR_THRESHOLD_LOW)
                .limit(MIN_ANCHORS)
                .collect(Collectors.toList());

        return lowAnchors;
    }

    private int clampHops(int maxHops) {
        return Math.max(1, Math.min(maxHops, MAX_HOP_LIMIT));
    }
}