#!/usr/bin/env python3
"""Standalone clock port identifier for Verilog/SystemVerilog files.

Identifies clock port names using the same naming-heuristic methodology as
hdlgen's ingestion pipeline (hdlgen/ingest/extractor.py).

Usage:
    python clock_ident.py design.sv
    python clock_ident.py design.sv --json
    python clock_ident.py design.sv --module pcie_msix
    python clock_ident.py dir_of_files/ --json

Algorithm:
    1. Parse ANSI and Verilog-2001 port declarations via regex.
    2. Filter to scalar (1-bit) input ports only.
    3. Match port names against a priority-ordered list of clock patterns.
    4. Return all matching clock port names (primary clock listed first).

Only port *names* are inspected — no RTL behavioural analysis is performed.
This matches hdlgen's detect_clocks() exactly.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path


# ---------------------------------------------------------------------------
# Port parsing (mirrors hdlgen/ingest/extractor.py _ANSI_PORT_RE / _PORT_DIR_RE)
# ---------------------------------------------------------------------------


@dataclass
class PortInfo:
    """Parsed port from a Verilog module."""

    name: str
    direction: str  # "input", "output", "inout"
    width: int = 1
    msb: int = 0
    lsb: int = 0


# ANSI-style ports in module header:
#   input wire [7:0] data_i
#   input logic clk
_ANSI_PORT_RE = re.compile(
    r"(input|output|inout)\s+"
    r"(?:reg\s+|wire\s+|logic\s+)?"
    r"(?:\w+::\w+\s+)?"  # optional qualified type (e.g. ibex_pkg::alu_op_e)
    r"(?:"
    r"\[(\d+):(\d+)\]"  # numeric bus width [N:M] — captured
    r"|"
    r"\[[^\]]+\]"  # parametric bus width [PARAM-1:0] — consumed, not captured
    r")?\s*"
    r"(\w+)",
)

# Verilog-2001 style: separate declarations after port list
_PORT_DIR_RE = re.compile(
    r"^\s*(input|output|inout)\s+"
    r"(?:reg\s+|wire\s+|logic\s+)?"
    r"(?:\w+::\w+\s+)?"  # optional qualified type
    r"(?:"
    r"\[(\d+):(\d+)\]"  # numeric bus width [N:M]
    r"|"
    r"\[[^\]]+\]"  # parametric bus width
    r")?\s*"
    r"(\w+)",
    re.MULTILINE,
)

# Module boundaries
_MODULE_RE = re.compile(r"\bmodule\s+(\w+)", re.MULTILINE)
_ENDMODULE_RE = re.compile(r"\bendmodule\b", re.MULTILINE)


# ---------------------------------------------------------------------------
# Clock detection patterns (mirrors hdlgen/ingest/extractor.py _CLOCK_PATTERNS)
# ---------------------------------------------------------------------------
# Ordered by priority — first match wins as the *primary* clock.

_CLOCK_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"^clk$", re.IGNORECASE),
    re.compile(r"^clock$", re.IGNORECASE),
    re.compile(r"^clk_i$", re.IGNORECASE),  # ibex convention
    re.compile(r"^pclk$", re.IGNORECASE),  # APB / peripheral clk
    re.compile(r"^hclk$", re.IGNORECASE),  # AHB clock
    re.compile(r"^aclk$", re.IGNORECASE),  # AXI clock
    re.compile(r"^sys_clk$", re.IGNORECASE),
    re.compile(r"^core_clk$", re.IGNORECASE),
    re.compile(r".*_core_clk$", re.IGNORECASE),  # nvdla_core_clk
    re.compile(r".*_clk$", re.IGNORECASE),
    re.compile(r"^clk_.*", re.IGNORECASE),
    re.compile(r".*clock.*", re.IGNORECASE),
    re.compile(r"^ck$", re.IGNORECASE),
]


# ---------------------------------------------------------------------------
# Port extraction
# ---------------------------------------------------------------------------


def _parse_port_match(m: re.Match, regex_id: str) -> PortInfo:
    """Build a PortInfo from a regex match (works for both ANSI and V2001)."""
    if regex_id == "ansi":
        direction, msb_s, lsb_s, name = m.group(1), m.group(2), m.group(3), m.group(4)
    else:
        direction, msb_s, lsb_s, name = m.group(1), m.group(2), m.group(3), m.group(4)

    if msb_s is not None and lsb_s is not None:
        msb, lsb = int(msb_s), int(lsb_s)
        width = abs(msb - lsb) + 1
    elif m.group(0) and "[" in m.group(0) and msb_s is None:
        # Parametric width — can't evaluate statically, treat as non-scalar
        # so it won't be considered a clock candidate.
        width, msb, lsb = 2, 1, 0  # sentinel: width > 1
    else:
        width, msb, lsb = 1, 0, 0

    return PortInfo(
        name=name, direction=direction.lower(), width=width, msb=msb, lsb=lsb
    )


def extract_ports(module_text: str) -> list[PortInfo]:
    """Extract ports from a single module's source text.

    Tries ANSI-style ports first (declarations in the module header),
    then falls back to Verilog-2001 style (separate declarations).
    """
    ports: list[PortInfo] = []
    seen: set[str] = set()

    for m in _ANSI_PORT_RE.finditer(module_text):
        p = _parse_port_match(m, "ansi")
        if p.name not in seen:
            ports.append(p)
            seen.add(p.name)

    # Also scan for V2001-style — some files mix styles or have
    # ports declared after the port list.
    for m in _PORT_DIR_RE.finditer(module_text):
        p = _parse_port_match(m, "v2001")
        if p.name not in seen:
            ports.append(p)
            seen.add(p.name)

    return ports


# ---------------------------------------------------------------------------
# Comment stripping
# ---------------------------------------------------------------------------

# Matches // line comments and /* block comments */ (non-greedy)
_COMMENT_RE = re.compile(
    r"//[^\n]*"  # line comment
    r"|/\*.*?\*/",  # block comment
    re.DOTALL,
)


def _strip_comments(source: str) -> str:
    """Remove Verilog comments, preserving newlines for line numbers."""

    def _replace(m: re.Match) -> str:
        # Preserve newlines so line count is stable
        return re.sub(r"[^\n]", " ", m.group(0))

    return _COMMENT_RE.sub(_replace, source)


# ---------------------------------------------------------------------------
# Module splitting
# ---------------------------------------------------------------------------


def split_modules(source_text: str) -> list[tuple[str, str]]:
    """Split source text into (module_name, module_text) pairs.

    Comments are stripped before scanning for ``module``/``endmodule``
    boundaries so that occurrences of the word "module" inside comments
    (e.g. ``// Top level module of the ibex core``) are not mistaken
    for real module declarations.  The original (uncommented) source is
    returned for port parsing so that nothing is lost.
    """
    # _strip_comments preserves string length (replaces non-newline chars
    # with spaces), so character offsets found in `stripped` map directly
    # back to `source_text`.
    stripped = _strip_comments(source_text)
    modules: list[tuple[str, str]] = []
    pos = 0
    while pos < len(stripped):
        m = _MODULE_RE.search(stripped, pos)
        if not m:
            break
        mod_name = m.group(1)
        em = _ENDMODULE_RE.search(stripped, m.end())
        if not em:
            break
        mod_end = em.end()
        # Include possible trailing comment on same line
        line_end = source_text.find("\n", mod_end)
        if line_end != -1:
            mod_end = line_end + 1
        # Return original source (with comments) for port parsing
        modules.append((mod_name, source_text[m.start() : mod_end]))
        pos = mod_end
    return modules


# ---------------------------------------------------------------------------
# Clock detection (mirrors hdlgen detect_clocks exactly)
# ---------------------------------------------------------------------------


def detect_clocks(ports: list[PortInfo]) -> list[str]:
    """Identify clock port names from a list of parsed ports.

    Returns clock port names ordered by priority (primary clock first).
    Only scalar (1-bit) input ports are considered candidates.
    """
    scalar_inputs = [p for p in ports if p.direction == "input" and p.width == 1]

    clocks: list[str] = []
    seen: set[str] = set()

    for pattern in _CLOCK_PATTERNS:
        for port in scalar_inputs:
            if port.name in seen:
                continue
            if pattern.match(port.name):
                clocks.append(port.name)
                seen.add(port.name)

    return clocks


# ---------------------------------------------------------------------------
# File / directory processing
# ---------------------------------------------------------------------------


@dataclass
class ModuleClocks:
    """Clock detection result for one module."""

    module_name: str
    source_file: str
    clock_ports: list[str]


def process_file(
    filepath: Path, module_filter: str | None = None
) -> list[ModuleClocks]:
    """Process a single Verilog/SystemVerilog file.

    Parameters
    ----------
    filepath:
        Path to the .v / .sv file.
    module_filter:
        If set, only report clocks for this module name.

    Returns
    -------
    list[ModuleClocks]
        One entry per module found in the file.
    """
    source = filepath.read_text(encoding="utf-8", errors="replace")
    modules = split_modules(source)
    results: list[ModuleClocks] = []

    for mod_name, mod_text in modules:
        if module_filter and mod_name != module_filter:
            continue
        ports = extract_ports(mod_text)
        clocks = detect_clocks(ports)
        results.append(
            ModuleClocks(
                module_name=mod_name,
                source_file=str(filepath),
                clock_ports=clocks,
            )
        )

    return results


def process_path(
    target: Path,
    module_filter: str | None = None,
    extensions: tuple[str, ...] = (".v", ".sv", ".vh", ".svh"),
) -> list[ModuleClocks]:
    """Process a file or directory of Verilog/SystemVerilog files."""
    if target.is_file():
        return process_file(target, module_filter)

    if target.is_dir():
        results: list[ModuleClocks] = []
        for f in sorted(target.rglob("*")):
            if f.is_file() and f.suffix in extensions:
                results.extend(process_file(f, module_filter))
        return results

    print(f"Error: {target} is not a file or directory", file=sys.stderr)
    sys.exit(1)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def main() -> None:
    '''Entrypoint for running clock_ident.py at the command line'''

    parser = argparse.ArgumentParser(
        description="Identify clock ports in Verilog/SystemVerilog files.",
        epilog=(
            "Uses the same naming-heuristic methodology as hdlgen's ingestion\n"
            "pipeline. Only scalar (1-bit) input ports matching known clock\n"
            "naming patterns are reported.\n\n"
            "Examples:\n"
            "  python clock_ident.py design.sv\n"
            "  python clock_ident.py rtl/ --json\n"
            "  python clock_ident.py design.sv --module pcie_msix\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "path",
        type=Path,
        help="Verilog/SystemVerilog file or directory to scan",
    )
    parser.add_argument(
        "--module",
        "-m",
        type=str,
        default=None,
        help="Only report clocks for this module name",
    )
    parser.add_argument(
        "--json",
        "-j",
        action="store_true",
        help="Output results as JSON",
    )
    parser.add_argument(
        "--quiet",
        "-q",
        action="store_true",
        help="Only print clock port names (one per line, no headers)",
    )

    args = parser.parse_args()
    results = process_path(args.path, module_filter=args.module)

    if not results:
        if args.module:
            print(f"No module named '{args.module}' found.", file=sys.stderr)
        else:
            print("No modules found.", file=sys.stderr)
        sys.exit(1)

    if args.json:
        out = [
            {
                "module": r.module_name,
                "file": r.source_file,
                "clocks": r.clock_ports,
            }
            for r in results
        ]
        print(json.dumps(out, indent=2))
    elif args.quiet:
        seen: set[str] = set()
        for r in results:
            for c in r.clock_ports:
                if c not in seen:
                    print(c)
                    seen.add(c)
    else:
        for r in results:
            if r.clock_ports:
                clocks_str = ", ".join(r.clock_ports)
                print(f"{r.source_file}  {r.module_name}:  {clocks_str}")
            else:
                print(f"{r.source_file}  {r.module_name}:  (no clock detected)")


if __name__ == "__main__":
    main()
