# Copyright 2026 Gaofeng Fan
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Testbench class for simulation setup."""

import inspect
import os
from datetime import datetime
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Union
from .circuit import _CircuitBase, Net, _get_unique_name, reset_global_name_counters
from .analysis import Analysis
from .save import SaveConfig, SaveOptions
from .devices import vsource # For docstring example


def _get_caller_filename() -> Optional[str]:
    """
    Try to get the filename of the script that called Testbench().
    Returns None if detection fails (e.g., in REPL, Jupyter, or exec()).
    """
    try:
        # Walk up the call stack to find the first frame outside this module
        for frame_info in inspect.stack():
            filename = frame_info.filename
            # Skip frames from this module (src/analogpy/testbench.py)
            if filename.endswith('analogpy/testbench.py') or filename.endswith('analogpy\\testbench.py'):
                continue
            # Skip virtual files (<stdin>, <string>, <frozen ...>, etc.)
            if filename.startswith('<'):
                continue
            # Skip site-packages (third-party libraries including pytest)
            if 'site-packages' in filename:
                continue
            # Found a user script - must be a real file
            if os.path.isfile(filename):
                basename = os.path.basename(filename)
                name, _ = os.path.splitext(basename)
                return name
    except Exception:
        pass
    return None


@dataclass
class SimulatorOptions:
    """Simulator options for Spectre.

    Only explicitly set fields are emitted. temp and tnom default to 27.
    Tolerance fields (reltol, vabstol, iabstol, gmin) default to None
    and are not emitted — let the command-line accuracy mode (++aps, etc.)
    control them unless you need explicit overrides.

    Use extras dict for arbitrary Spectre options not covered by named fields.

    Example:
        opts = SimulatorOptions(temp=85, gmin=1e-15)
        opts = SimulatorOptions(extras={"homotopy": "all", "rforce": 1})
    """
    temp: float = 27
    tnom: float = 27
    reltol: Optional[float] = None
    vabstol: Optional[float] = None
    iabstol: Optional[float] = None
    gmin: Optional[float] = None
    maxnotes: Optional[int] = None
    maxwarns: Optional[int] = None
    extras: Dict[str, Any] = field(default_factory=dict)

    def to_spectre(self) -> str:
        parts = [f'simulatorOptions options temp={self.temp} tnom={self.tnom}']
        if self.reltol is not None:
            parts.append(f'reltol={self.reltol}')
        if self.vabstol is not None:
            parts.append(f'vabstol={self.vabstol}')
        if self.iabstol is not None:
            parts.append(f'iabstol={self.iabstol}')
        if self.gmin is not None:
            parts.append(f'gmin={self.gmin}')
        if self.maxnotes is not None:
            parts.append(f'maxnotes={self.maxnotes}')
        if self.maxwarns is not None:
            parts.append(f'maxwarns={self.maxwarns}')
        for k, v in self.extras.items():
            parts.append(f'{k}={v}')
        return ' '.join(parts)


class Testbench(_CircuitBase):
    """
    Test environment for circuit simulation.

    Testbench extends Circuit with:
    - Analysis definitions (DC, AC, Transient)
    - Simulator options
    - Save configurations
    - Behavioral models (Verilog-A includes)
    - Global nets
    - Optional ports (for AMS co-simulation with digital stimulus)

    Example:
        # Standalone testbench (no ports)
        tb = Testbench("tb_amplifier")
        vin = tb.net("vin")
        gnd = tb.gnd()
        vdd_net = tb.net("vdd")

        tb.instantiate(vsource, instance_name="I_Vdd", p=vdd_net, n=gnd, dc=1.8)
        tb.instantiate(amplifier, "X_AMP", inp=vin, out=vout, vdd=vdd, gnd=gnd)
        tb.add_analysis(DC())

        # AMS testbench with ports (for digital stimulus)
        tb = Testbench("tb_ams", ports=["CLK", "DATA_IN", "DATA_OUT"])
        tb.add_analysis(Tran(stop=100e-9))
    """

    def __init__(self, name: str = None, ports: List[str] = None):
        # Auto-detect name if not provided
        if name is None:
            name = _get_caller_filename()
        if name is None:
            # Fallback: use datetime-based name
            name = f"testbench_{datetime.now().strftime('%Y%m%d_%H%M%S')}"

        # Use global counter shared with Circuit to avoid name collisions
        final_name = _get_unique_name(name)

        super().__init__(final_name)
        self.ports: List[str] = ports or []
        self.analyses: List[Analysis] = []
        self.options: SimulatorOptions = SimulatorOptions()
        self.save_options: SaveOptions = SaveOptions()
        self._save_configs: List[SaveConfig] = []
        self._save_signals: List[str] = []  # Direct signal saves
        self.behavioral_models: List[str] = []
        self.global_nets: List[str] = ["0"]  # Ground is always global
        self._include_paths: List[str] = []
        self._save_mode: str = "explicit"  # "all", "explicit", "none"

        # Pre-create nets for all ports
        for port in self.ports:
            self.net(port)

    @classmethod
    def reset_name_counters(cls):
        """Reset the global name counters. Useful for testing."""
        reset_global_name_counters()

    def port(self, name: str) -> Net:
        """
        Add a port to this testbench and return its net.

        Useful for AMS simulation where digital blocks provide stimulus.

        Example:
            clk = tb.port("CLK")
            data_in = tb.port("DATA_IN")
        """
        if name not in self.ports:
            self.ports.append(name)
        return self.net(name)

    def add_analysis(self, analysis: Analysis) -> 'Testbench':
        """Add an analysis to the testbench."""
        self.analyses.append(analysis)
        return self

    def set_options(self, **kwargs) -> 'Testbench':
        """Set simulator options."""
        for key, value in kwargs.items():
            if hasattr(self.options, key):
                setattr(self.options, key, value)
            else:
                raise ValueError(f"Unknown simulator option: {key}")
        return self

    def set_temp(self, temp: float) -> 'Testbench':
        """Set simulation temperature."""
        self.options.temp = temp
        return self

    def behavioral_model(self, path: str) -> 'Testbench':
        """
        Add a Verilog-A or other behavioral model.

        Args:
            path: Path to the model file (.va, etc.)
        """
        self.behavioral_models.append(path)
        return self

    def include_path(self, path: str) -> 'Testbench':
        """Add an include path for model files."""
        self._include_paths.append(path)
        return self

    def global_net(self, name_or_net: Union[str, Net]) -> Net:
        """
        Declare a global net.

        Args:
            name_or_net: Net name string or Net object
        """
        name = name_or_net.name if isinstance(name_or_net, Net) else name_or_net
        if name not in self.global_nets:
            self.global_nets.append(name)
        return self.net(name)

    # ========== Save Control ==========

    def save(self, config_or_signal: Union[SaveConfig, str, Net], enabled: bool = True) -> 'Testbench':
        """
        Add signals to save.

        Args:
            config_or_signal: SaveConfig object, signal name string, or Net object
            enabled: Whether to enable this save (for conditional saves)
        """
        if not enabled:
            return self

        if isinstance(config_or_signal, SaveConfig):
            self._save_configs.append(config_or_signal)
        elif isinstance(config_or_signal, Net):
            # Accept Net object directly - use its name
            self._save_signals.append(config_or_signal.name)
        elif isinstance(config_or_signal, str):
            self._save_signals.append(config_or_signal)
        return self

    def save_mode(self, mode: str) -> 'Testbench':
        """
        Set global save behavior.

        Args:
            mode: "all" (allpub), "explicit" (only specified), "none"
        """
        if mode not in ("all", "explicit", "none"):
            raise ValueError(f"Invalid save mode: {mode}")
        self._save_mode = mode
        self.save_options.mode = "all" if mode == "all" else "selected"
        return self

    def save_only(self, *signals: Union[str, Net]) -> 'Testbench':
        """
        Override all saves - only save these specific signals.

        Args:
            *signals: Signal names or Net objects to save.
        """
        self._save_configs.clear()
        self._save_signals = [s.name if isinstance(s, Net) else s for s in signals]
        self._save_mode = "explicit"
        return self

    def get_all_saves(self) -> SaveConfig:
        """Combine all save configurations into one."""
        combined = SaveConfig("combined")
        for config in self._save_configs:
            combined.merge(config)
        # Add direct signals
        for sig in self._save_signals:
            combined.voltage(sig)
        return combined

    def render_schematic(self, output_path: str, depth: int = 100, title: Optional[str] = None, include_legend: bool = True):
        """
        Generate a schematic PDF for this testbench.

        Args:
            output_path: Path to save the PDF (e.g., 'schematic.pdf')
            depth: Maximum recursion depth for subcircuits (default 100)
            title: Optional title for the documentation
            include_legend: Whether to include a color legend as the last page
        """
        from .visualization import SchematicBook
        
        book = SchematicBook(title=title or f"Testbench: {self.name}")
        book.add_testbench(self, depth=depth)
        book.render(output_path, include_legend=include_legend)
