"""Utility functions using pyslang for SystemVerilog analysis."""

# TODO(drew): this module is unused for --tool=riviera --coverage until pyslang > 10.0.0
# also, lacking pytests in opencos.tests

import fnmatch
import importlib.util
import importlib.metadata
import logging
import re
from typing import Optional

from opencos import util
from opencos.util import import_module_from_string

logger = logging.getLogger(__name__)

INSTALL_HELPER_STR = '''
Install "pyslang" with one of:
        python venv:
            --> pip install pyslang
            --> pip install opencos-eda[pyslang]
            --> pip install opencos-eda[pyslang] --force-reinstall
        uv:
            --> uv tool install opencos-eda[pyslang]
            --> uv tool install opencos-eda[pyslang] --force-reinstall
        uv venv:
            --> uv pip install opencos-eda[pyslang]
            --> uv pip install opencos-eda[pyslang] --force-reinstall
        uv run to call "eda":
            --> uv run --extra pyslang eda ....
'''

PYSLANG_AVAILABLE = None
PYSLANG_VERSION = ''

class Imported:
    '''Namespace for optionallly imported pymodules'''
    package_names: list  = ['pyslang', 'pyslang.ast', 'pyslang.syntax']

    pyslang = None
    pyslang_ast = None
    pyslang_syntax = None


def init(quiet: bool = False) -> None:
    '''
    Sets global PYSLANG_AVAILABLE if not True/False yet, returns None

    This does not run by default, only when called by methods in this
    module
    '''
    global PYSLANG_AVAILABLE  # pylint: disable=global-statement
    global PYSLANG_VERSION    # pylint: disable=global-statement

    if PYSLANG_AVAILABLE is not None:
        # has been set to true/false:
        return

    try:
        PYSLANG_VERSION = importlib.metadata.version('pyslang')
        util.debug(f'Found pyslang version: {PYSLANG_VERSION}')
        spec = importlib.util.find_spec('pyslang')
        PYSLANG_AVAILABLE = bool(PYSLANG_VERSION) and bool(spec)
    except importlib.metadata.PackageNotFoundError:
        # TODO(drew): this message isn't correct, we don't have a pyslang
        # optional package in opencos-eda[pyslang]:
        if not quiet:
            util.warn_once(
                'pyslang package not installed in python environment.',
                INSTALL_HELPER_STR,
                warn_name='pyslang_package_available'
            )
    except Exception as e:
        util.error(f'Failed to check pyslang version: {e}')

    # check other packages we use:
    if PYSLANG_AVAILABLE:
        for pn in Imported.package_names:
            if _imported := import_module_from_string(pn):
                setattr(Imported, pn.replace('.', '_'), _imported)
            else:
                if not quiet:
                    util.warn_once(
                        f'{pn} package not installed in python environment.',
                        INSTALL_HELPER_STR,
                        warn_name='pyslang_package_available'
                    )
                break


def check_pyslang_available() -> bool:
    '''Returns PYSLANG_AVAILABLE (called after init())'''

    if PYSLANG_AVAILABLE is None:
        init()
    return PYSLANG_AVAILABLE


class PortInfo:
    '''PortInfo, helper with pyslang'''
    def __init__(self, name: str, direction: str, width: int, hier_path: str):
        (self.name, self.direction, self.width, self.hier_path) = (
            name, direction, width, hier_path
        )

    def __repr__(self):
        return f"PortInfo({self.name}, {self.direction}, {self.width}b, {self.hier_path})"


class SignalInfo:
    '''SignalInfo, helper with pyslang'''
    def __init__(self, name: str, kind: str, width: int, hier_path: str, signal_type: str = ''):
        (self.name, self.kind, self.width, self.hier_path, self.signal_type) = (
            name, kind, width, hier_path, signal_type
        )

    def __repr__(self):
        type_str = f", {self.signal_type}" if self.signal_type else ""
        return f"SignalInfo({self.name}, {self.kind}, {self.width}b{type_str}, {self.hier_path})"


class CoverpointInfo:
    '''CoverpointInfo, helper with pyslang'''
    def __init__(self, name: str, type_str: str, hier_path: str):
        self.name, self.type_str, self.hier_path = name, type_str, hier_path

    def __repr__(self):
        return f"CoverpointInfo({self.name}, {self.type_str}, {self.hier_path})"


class CovergroupInfo:
    '''CovergroupInfo, helper with pyslang'''
    def __init__(self, name: str, hier_path: str):
        self.name, self.hier_path, self.coverpoints = name, hier_path, []

    def __repr__(self):
        return (
            f"CovergroupInfo({self.name}, {len(self.coverpoints)} coverpoints, {self.hier_path})"
        )



def build_compilation(files: list) -> 'pyslang.ast.Compilation':
    '''Attempts to add an AST from files'''
    comp = Imported.pyslang_ast.Compilation()
    for filepath in files:
        try:
            comp.addSyntaxTree(Imported.pyslang_syntax.SyntaxTree.fromFile(filepath))
        except Exception as exc:
            logger.debug("Failed to parse %s: %s", filepath, exc)
            continue
    comp.getAllDiagnostics()
    return comp


def extract_ports_from_files(
        files: list, top_module: str,
        incdirs: Optional[list] = None, # pylint: disable=unused-argument
        defines: Optional[dict] = None  # pylint: disable=unused-argument
) -> list:
    '''Returns list of ports from files'''

    if not check_pyslang_available():
        util.error('"pyslang" is required, see warnings for install instructions.')
        return []

    if not files:
        return []

    comp = build_compilation(files)

    for inst in comp.getRoot().topInstances:
        if inst.name == top_module:
            return extract_ports_from_instance(inst, f"/{top_module}")
    return []


def _find_file_with_module(files: list, module_name: str) -> Optional[str]:
    '''Returns filepath containing a module, given a list of files to search'''
    pattern = re.compile(rf'^\s*module\s+{re.escape(module_name)}\b', re.MULTILINE)
    for filepath in files:
        try:
            with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
                if pattern.search(f.read()):
                    return filepath
        except (OSError, IOError) as exc:
            logger.debug("Failed to read %s: %s", filepath, exc)
            continue
    return None


def extract_ports_from_text(code: str, top_module: Optional[str] = None) -> list:
    '''Returns list of ports given str (arg -- code)'''

    if not check_pyslang_available():
        util.error('"pyslang" is required, see warnings for install instructions.')
        return []

    comp = Imported.pyslang_ast.Compilation()
    comp.addSyntaxTree(Imported.pyslang_syntax.SyntaxTree.fromText(code))
    comp.getAllDiagnostics()

    for inst in comp.getRoot().topInstances:
        if top_module is None or inst.name == top_module:
            return extract_ports_from_instance(inst, f"/{inst.name}")
    return []


def extract_ports_from_instance(inst, hier_prefix: str) -> list:
    '''Returns list of ports from a given instance'''
    ports = []
    for member in inst.body:
        if member.kind == Imported.pyslang_ast.SymbolKind.Port:
            direction = str(member.direction).rsplit('.', maxsplit=1)[-1]
            try:
                width = member.internalSymbol.type.bitWidth
            except AttributeError:
                width = 1
            ports.append(PortInfo(member.name, direction, width, f"{hier_prefix}/{member.name}"))
    return ports


def match_glob_pattern(name: str, patterns: list) -> bool:
    """Check if name matches any of the given glob patterns (case-insensitive)."""
    return any(
        fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns
    )


def extract_signals_from_instance( # pylint: disable=too-many-locals,too-many-branches
        inst,
        hier_prefix: str,
        include_ports: bool = True,
        include_nets: bool = True,
        include_variables: bool = True,
        max_depth: int = 1,
        current_depth: int = 0,
        include_patterns: Optional[list] = None,
        exclude_patterns: Optional[list] = None
) -> list:
    '''Returns list of signals from an instance in an AST'''

    if not check_pyslang_available():
        return []

    signals = []
    include_patterns = include_patterns or []
    exclude_patterns = exclude_patterns or []
    body = inst.body if hasattr(inst, 'body') else inst

    for member in body:
        kind, name = member.kind, member.name

        if (exclude_patterns and match_glob_pattern(name, exclude_patterns)) or \
           (include_patterns and not match_glob_pattern(name, include_patterns)):
            continue

        if kind == Imported.pyslang_ast.SymbolKind.Port and include_ports:
            if hasattr(member, 'internalSymbol'):
                width = getattr(member.internalSymbol.type, 'bitWidth', 1)
            else:
                width = 1
            signals.append(
                SignalInfo(
                    name, 'Port', width, f"{hier_prefix}/{name}",
                    str(member.direction).rsplit('.', maxsplit=1)[-1]
                )
            )

        elif kind == Imported.pyslang_ast.SymbolKind.Net and include_nets:
            if hasattr(member, 'type'):
                width = getattr(member.type, 'bitWidth', 1)
            else:
                width = 1
            if hasattr(member, 'netType'):
                net_type = str(member.netType).rsplit('.', maxsplit=1)[-1]
            else:
                net_type = 'wire'
            signals.append(
                SignalInfo(name, 'Net', width, f"{hier_prefix}/{name}", net_type)
            )

        elif kind == Imported.pyslang_ast.SymbolKind.Variable and include_variables:
            if hasattr(member, 'type'):
                width = getattr(member.type, 'bitWidth', 1)
            else:
                width = 1
            signals.append(
                SignalInfo(name, 'Variable', width, f"{hier_prefix}/{name}", 'logic')
            )

        elif kind == Imported.pyslang_ast.SymbolKind.Instance and current_depth < max_depth:
            signals.extend(
                extract_signals_from_instance(
                    member, f"{hier_prefix}/{name}", include_ports,
                    include_nets, include_variables, max_depth, current_depth + 1,
                    include_patterns, exclude_patterns
                )
            )

    return signals


def find_instance_paths(
        files: list,
        target_module: str,
        top_module: Optional[str] = None, # pylint: disable=unused-argument
        incdirs: Optional[list] = None,   # pylint: disable=unused-argument
        defines: Optional[dict] = None    # pylint: disable=unused-argument
) -> list:
    '''Returns list of instances from an AST'''
    if not check_pyslang_available():
        return []

    comp = build_compilation(files)
    instances = []

    def visitor(symbol):
        if (symbol.kind == Imported.pyslang_ast.SymbolKind.Instance and
            hasattr(symbol, 'definition') and symbol.definition.name == target_module):
            instances.append(f"/{symbol.hierarchicalPath}")

    comp.getRoot().visit(visitor)
    return instances


def generate_toggle_tcl_commands(
        ports: list, include_inputs: bool = True, include_outputs: bool = True,
        include_inouts: bool = True
) -> list:
    '''Helper for Toggle coverage, returns list of toggle commands (tool specific)

    Given AST ports
    '''
    commands = []
    for port in ports:
        if any((port.direction == 'In' and include_inputs,
                port.direction == 'Out' and include_outputs,
                port.direction == 'InOut' and include_inouts)):
            commands.append(f"toggle {port.hier_path}")
    return commands


def extract_covergroups_from_instance(
        inst, hier_prefix: str, max_depth: int = 2, current_depth: int = 0
) -> list:
    '''Returns list of covergroups from an AST'''

    if not check_pyslang_available():
        return []

    covergroups = []
    body = inst.body if hasattr(inst, 'body') else inst

    for member in body:
        if member.kind == Imported.pyslang_ast.SymbolKind.Covergroup:
            cg_info = CovergroupInfo(member.name, f"{hier_prefix}/{member.name}")
            for cg_member in member.body:
                if cg_member.kind == Imported.pyslang_ast.SymbolKind.Coverpoint:
                    cg_info.coverpoints.append(CoverpointInfo(
                        cg_member.name,
                        str(cg_member.type) if hasattr(cg_member, 'type') else 'unknown',
                        f"{cg_info.hier_path}/{cg_member.name}"
                    ))
            covergroups.append(cg_info)
        elif member.kind == Imported.pyslang_ast.SymbolKind.Instance and \
             current_depth < max_depth:
            covergroups.extend(
                extract_covergroups_from_instance(
                    member, f"{hier_prefix}/{member.name}", max_depth, current_depth + 1
                )
            )

    return covergroups
