#!/usr/bin/env python3

"""
Check for cyclic inter-directory dependencies in C++ includes.

If a source file in directory A includes headers from directory B,
then no source file in B may include a header from A, neither directly
nor indirectly.

Copyright Forschungszentrum Jülich GmbH 2025.
License:  Public Domain
"""

import check_utils as cu
import os, re, sys
from collections import defaultdict

####################################################################################################
# Build dependency graph
####################################################################################################

def extract_top_level_dir(filepath):
    """
    Extract the top-level directory from a filepath.
    E.g., './Base/Util/Vec.h' -> 'Base'
          'Device/Data/SimDataVec.h' -> 'Device'
    Returns None if file is not in a recognizable top-level directory.
    """
    parts = filepath.replace('\\', '/').split('/')
    # Remove leading './' if present
    if parts[0] == '.':
        parts = parts[1:]
    if len(parts) > 0 and parts[0]:
        # Top-level directory is the first component
        return parts[0]
    return None

def extract_includes(filepath):
    """
    Extract all #include directives from a C++ file.
    Returns a list of included file paths (e.g., ['Base/Util/Vec.h', 'Device/Data/foo.h'])
    """
    includes = []
    try:
        with open(filepath, 'r') as f:
            for line in f:
                # Match #include "..." (local includes)
                m = re.match(r'^\s*#include\s+"([^"]+)"', line)
                if m:
                    includes.append(m.group(1))
    except Exception as e:
        print(f"Warning: Could not read {filepath}: {e}", file=sys.stderr)
    return includes

def build_dependency_graph(files):
    """
    Build a directed graph of dependencies between top-level directories.
    Returns:
        - graph: dict mapping directory -> set of directories it depends on
        - details: dict mapping (dir_from, dir_to) -> list of (source_file, included_file) examples
    """
    graph = defaultdict(set)
    details = defaultdict(list)

    for filepath in files:
        source_dir = extract_top_level_dir(filepath)
        if not source_dir:
            continue

        includes = extract_includes(filepath)
        for inc in includes:
            target_dir = extract_top_level_dir(inc)
            if target_dir and target_dir != source_dir:
                # Directory source_dir depends on target_dir
                graph[source_dir].add(target_dir)
                details[(source_dir, target_dir)].append((filepath, inc))

    return graph, details

####################################################################################################
# Cycle detection using DFS
####################################################################################################

def find_cycles(graph):
    """
    Find all cycles in the dependency graph using DFS.
    Returns a list of cycles, where each cycle is a list of directory names.
    """
    cycles = []
    visited = set()
    rec_stack = set()
    path = []

    def dfs(node):
        visited.add(node)
        rec_stack.add(node)
        path.append(node)

        for neighbor in sorted(graph.get(node, [])):
            if neighbor not in visited:
                if dfs(neighbor):
                    return True
            elif neighbor in rec_stack:
                # Found a cycle
                cycle_start = path.index(neighbor)
                cycle = path[cycle_start:] + [neighbor]
                cycles.append(cycle)

        path.pop()
        rec_stack.remove(node)
        return False

    # Check all nodes as potential starting points
    for node in sorted(graph.keys()):
        if node not in visited:
            dfs(node)

    return cycles

####################################################################################################
# Main
####################################################################################################

if __name__ == "__main__":

    assert len(sys.argv) == 1, "Check script must be called without arguments"

    # Get all C++ files
    files = cu.all_files()

    # Build dependency graph
    graph, details = build_dependency_graph(files)

    # Find cycles
    cycles = find_cycles(graph)

    if len(cycles) > 0:
        print("Found cyclic inter-directory dependencies:")
        print()

        # Report unique cycles (avoid reporting the same cycle multiple times)
        reported = set()
        for cycle in cycles:
            # Normalize cycle representation to avoid duplicates
            # (a cycle can be represented starting from any node)
            min_idx = cycle.index(min(cycle))
            normalized = tuple(cycle[min_idx:] + cycle[:min_idx])

            if normalized in reported:
                continue
            reported.add(normalized)

            print("Cycle detected:")
            for i in range(len(cycle) - 1):
                dir_from = cycle[i]
                dir_to = cycle[i + 1]
                print(f"  {dir_from} -> {dir_to}")

                # Show example files
                examples = details.get((dir_from, dir_to), [])
                if examples:
                    # Show first few examples
                    for src, inc in examples[:3]:
                        print(f"    e.g., {src} includes {inc}")
                    if len(examples) > 3:
                        print(f"    ... and {len(examples) - 3} more")
            print()

        sys.exit(1)

    sys.exit(0)
