# others
import json
import subprocess
from pathlib import Path
from typing import Dict, List

import mpflash.basicgit as git
import pytest
from helpers import read_stub

pytestmark = [pytest.mark.stubber, pytest.mark.doc_stubs]

from stubber.rst.lookup import TYPING_IMPORT
from stubber.rst.reader import RSTWriter

# SOT
from stubber.stubs_from_docs import generate_from_rst

XFAIL_DOCFIX = True  # True to not fail on missing doc fixes


###################################################################################################
# Fixtures for re-use by different test methods
# shared across this module
###################################################################################################
@pytest.fixture(scope="module")
def pyright_results(rst_stubs, pytestconfig: pytest.Config) -> Dict[str, Dict]:
    "Run pyright over folder with rst generated stubs, and return the results"
    # TODO: this fails if the nodeJS version of pyright is installed , and only works for the SLOWER pip install Pyright
    test_config = pytestconfig.rootpath / "tests/pyrightconfig.json"
    cmd = [
        "pyright",
        "--project",
        test_config.absolute().as_posix(),
        "--outputjson",
        rst_stubs.as_posix(),
    ]
    try:
        # run pyright in the docstub folder to allow modules to import each other.
        result = subprocess.run(cmd, shell=False, capture_output=True, cwd=rst_stubs.as_posix())
    except OSError as e:
        raise e
    results = {}
    try:
        results = json.loads(result.stdout)
    except Exception:
        assert 0, "Could not load pyright's JSON output :-("
    return results


def _current_git_ref(repo: str) -> str:
    """Return the current branch name, or the commit sha if HEAD is detached.

    Used to restore a repo to its original checkout after a test switches branches.
    """
    branch = subprocess.run(
        ["git", "-C", repo, "symbolic-ref", "--short", "-q", "HEAD"],
        capture_output=True,
        text=True,
    ).stdout.strip()
    if branch:
        return branch
    return subprocess.run(
        ["git", "-C", repo, "rev-parse", "HEAD"],
        capture_output=True,
        text=True,
    ).stdout.strip()


@pytest.fixture(scope="module")
def micropython_repo(testrepo_micropython: Path, testrepo_micropython_lib: Path):
    "make sure a recent branch is checked out, then restore the original checkout"
    repo = testrepo_micropython.as_posix()
    # capture the current checkout so these tests do not leave the (real) repo on
    # another branch/version - see the reflog issue where it was left on 'master'.
    original_ref = _current_git_ref(repo)

    git.switch_branch("master", repo)
    try:
        yield git.get_local_tag(repo) or "xx_x"
    finally:
        # restore the repo to the checkout it had before the tests ran (runs even
        # if a test fails)
        if original_ref:
            subprocess.run(["git", "-C", repo, "checkout", "--quiet", original_ref], capture_output=True, text=True)


@pytest.fixture(scope="module")
def rst_stubs(tmp_path_factory: pytest.TempPathFactory, micropython_repo, testrepo_micropython: Path):
    "Generate stubs from RST files - once for this module"
    v_tag = micropython_repo
    # setup our on folder for testing
    dst_folder = tmp_path_factory.mktemp("stubs") / v_tag
    rst_folder = testrepo_micropython / "docs/library"
    generate_from_rst(rst_folder, dst_folder, v_tag=v_tag)
    yield dst_folder

    # cleanup code here


###################################################################################################
#
###################################################################################################


@pytest.mark.docfix
def test_rst_all(tmp_path, micropython_repo, testrepo_micropython: Path):
    v_tag = micropython_repo

    rst_folder = Path(testrepo_micropython.as_posix()) / "docs/library"
    dst_folder = tmp_path / "noblack"
    x = generate_from_rst(rst_folder, dst_folder, v_tag=v_tag, format=False)
    assert type(x) == int, "returns a number"
    assert x > 0, "should generate at least 1 file"


@pytest.mark.parametrize(
    "filename, expected",
    [
        (
            "tests/rst/data/function_10.rst",
            [
                "def wake_on_ext0(pin, level) -> Incomplete:",
            ],
        ),
        (
            "tests/rst/data/function_11.rst",
            [
                "def wake_on_ext0(pin, level) -> Incomplete:",
            ],
        ),
        (
            "tests/rst/data/function_12.rst",
            [
                "def wake_on_touch(wake) -> None:",
                "        Configure whether or not a touch will wake the device from sleep.",
                "def wake_on_ext0(pin, level) -> None:",
            ],
        ),
    ],
)
def test_rst_parse_function(filename, expected):
    # testcase = FN_1
    r = RSTWriter()
    r.read_file(Path(filename))
    # process
    r.parse()
    r.prepare_output()
    # check
    assert len(r.output) > 1
    for fn in expected:
        assert fn in [l.rstrip() for l in r.output]


CLASS_10 = [
    "class Partition:",
    "    def __init__(self, id) -> None:",
    "    @classmethod",
    "    def find(cls, type=TYPE_APP, subtype=0xff, label=None) -> List:",
    #    "    def info(self, ) -> Any:",
    # "    def readblocks(self, block_num, buf) -> Any:",
    # "    def writeblocks(self, block_num, buf) -> Any:",
]


@pytest.mark.parametrize(
    "line",
    [
        "class Partition():",
        "    def __init__(self, id) -> None:",
        "    @classmethod",
        "    def find(cls, type=TYPE_APP, subtype=0xff, label=None) -> List:",
        "    def info(self) -> Tuple:",
        "    def readblocks(self, block_num, buf, offset: Optional[int] = 0) -> Incomplete:",
        "    def writeblocks(self, block_num, buf, offset: Optional[int] = 0) -> Incomplete:",
    ],
)
# def test_rst_parse_class_10(expected: List[str]):
def test_rst_parse_class_10(line: str):
    # testcase = FN_1
    r = RSTWriter()
    r.read_file(Path("tests/rst/data/class_10.rst"))
    # process
    r.parse()
    r.prepare_output()  # cleanup output
    # check if each expected line appears in the output
    # there can be more

    assert len(r.output) > 1
    assert line in [l.rstrip() for l in r.output], f"did not generate : '{line}'"


@pytest.mark.parametrize(
    "param_in, expected",
    [
        ("", ""),
        ("()", "()"),
        ("() :", "()"),  # strip additional stuff
        ("(\\*, something)", "(*, something)"),  # wrong escaping
        ("([angle])", "(angle: Optional[Any]=None)"),  # simple optional
        ("([angle, time=0])", "(angle: Optional[Any]=None, time=0)"),  # dual optional - hardcoded
        ("('param')", "(param)"),
        (
            "(if_id=0, config=['dhcp' or configtuple])",
            "(if_id=0, config: Union[str,Tuple]='dhcp')",
        ),
        ("lambda)", "lambda_fn)"),
        ("(block_device or path)", "(block_device_or_path)"),
        # network - AbstractNIC.connect
        # ("([service_id, key=None, *, ...])", "(service_id, key=None, *args, **kwargs: Optional[Any])"), # old - incorrect
        (
            "([service_id, key=None, *, ...])",
            "(service_id: Optional[Any]=None, key: Optional[Any]=None, *args, **kwargs)",
        ),  # New
        ("(block_num, buf, offset)", "(block_num, buf, offset: Optional[int] = 0)"),
        ("(key, mode[, IV])", "(key, mode, IV: Optional[Any]=None)"),
        ("([(ip, subnet, gateway, dns)])", "(configtuple: Optional[Any]=None)"),
        # a different notations of optionals
        ("(id1, [foo], [bar])", "(id1, foo: Optional[Any]=None, bar: Optional[Any]=None)"),
        ("(id2, [foo] [,bar])", "(id2, foo: Optional[Any]=None ,bar: Optional[Any]=None)"),
        # ("(id3, [foo, bar])","(id3, foo: Optional[Any]=None, bar: Optional[Any]=None)"),
        # with assignments
        ("(id4, foo=-1, [bar])", "(id4, foo=-1, bar: Optional[Any]=None)"),
        # ("(id5, foo=-1, [bar=2])","(id5, foo=-1, bar: Optional[Any]=2)"),
        # ESPNow extra notation
        ("ESPNow.config('param')   (ESP32 only)", "ESPNow.config(param)"),
        # machine.USBDevice
        # Positional Only indicator should not be removed / `USBDevice.active(self, [value] /)`
        (
            "USBDevice.active(self, [value]/)",
            "USBDevice.active(self, value: Optional[Any]=None ,/)",
        ),
        (
            "USBDevice.active(self, [value] /)",
            "USBDevice.active(self, value: Optional[Any]=None ,/)",
        ),
        # USBDevice.active(self, [value] /)
        # machine.CAN - **kwargs should be preserved (not converted to *kwargs)
        ("(id, *args, **kwargs)", "(id, *args, **kwargs)"),
    ],
)
def test_fix_param(param_in, expected):
    "validate known parameter typing notation errors"
    r = RSTWriter()
    result = r.fix_parameters(param_in)
    assert result == expected


def test_import_typing():
    "always include typing"
    r = RSTWriter()
    r.prepare_output()
    for line in TYPING_IMPORT:
        assert line.strip() in [l.rstrip() for l in r.output], f"did not import typing : '{line}'"


def test_fix_param_dynamic():
    r = RSTWriter()

    # in 'machine' module

    param_in = "(*, trigger, handler=None, wake=machine.IDLE)"
    param_out = "(*, trigger, handler=None, wake=IDLE)"

    # in module
    r.current_module = "machine"
    result = r.fix_parameters(param_in)
    assert result == param_out

    r.current_module = ""
    result = r.fix_parameters(param_in)
    assert result != param_out
    assert result == param_in

    # -----------------------
    param_in = "baudrate=1000000, *, polarity=0, phase=0, bits=8, firstbit=SPI.MSB, sck=None, mosi=None, miso=None)"
    param_out = "baudrate=1000000, *, polarity=0, phase=0, bits=8, firstbit=MSB, sck=None, mosi=None, miso=None)"

    # in class
    r.current_class = "SPI"
    result = r.fix_parameters(param_in)
    assert result == param_out
    # not in class
    r.current_class = ""
    result = r.fix_parameters(param_in)
    assert result != param_out
    assert result == param_in


@pytest.mark.docfix
def test_pyright_Non_default_follows_default(pyright_results, capsys):
    """use pyright to check the validity of the generated stubs
    - Non-default argument follows default argument
    """
    issues: List[Dict] = pyright_results["generalDiagnostics"]
    # workaround : "Non-default argument follows default argument", does not specify rule
    # TODO: apparently the mypy.stubgen does not quite get this right in generating the .pyi
    # , so only flag errors in .py files

    issues = list(
        filter(
            lambda diag: "message" in diag.keys()
            and diag["message"] == "Non-default argument follows default argument"
            and diag["file"].endswith(".py"),
            issues,
        )
    )
    for issue in issues:
        print(f"{issue['message']} in {issue['file']} line {issue['range']['start']['line']}")
    assert len(issues) == 0


@pytest.mark.docfix
@pytest.mark.xfail(reason="upstream docfix needed", condition=XFAIL_DOCFIX)
def test_pyright_undefined_variable(pyright_results, capsys):
    "use pyright to check the validity of the generated stubs"
    issues: List[Dict] = pyright_results["generalDiagnostics"]
    issues = list(
        filter(
            lambda diag: "rule" in diag.keys() and diag["rule"] == "reportUndefinedVariable",
            issues,
        )
    )
    for issue in issues:
        print(f"{issue['file']}:{issue['range']['start']['line']}:{issue['range']['start']['character']} - {issue['message']}  ")
    assert len(issues) == 0, "there should be no `Undefined Variables`"


@pytest.mark.docfix
def test_pyright_reportGeneralTypeIssues(pyright_results, capsys):
    "use pyright to check the validity of the generated stubs - reportGeneralTypeIssues"
    issues: List[Dict] = pyright_results["generalDiagnostics"]
    # workaround : "Non-default argument follows default argument", does not specify rule
    issues = list(
        filter(
            lambda diag: "rule" in diag.keys()
            and diag["rule"] == "reportGeneralTypeIssues"
            and not "is obscured by a declaration" in diag["message"],
            issues,
        )
    )
    for issue in issues:
        print(f"{issue['message']} in {issue['file']} line {issue['range']['start']['line']}")
    # 'Cannot access member "MSB" for type "Type[SPI]" 'Member "MSB" is unknown'
    assert len(issues) <= 1, "There should be no type issues"


@pytest.mark.docfix
def test_pyright_invalid_strings(pyright_results, capsys):
    "use pyright to check the validity of the generated stubs"
    issues: List[Dict] = pyright_results["generalDiagnostics"]
    # some issues do not have a rule
    issues = [i for i in issues if "rule" in i.keys()]
    # Only fail on errors
    issues = list(filter(lambda diag: diag["severity"] == "error", issues))
    issues = list(filter(lambda diag: diag["rule"] == "reportInvalidStringEscapeSequence", issues))
    for issue in issues:
        print(f"{issue['message']} in {issue['file']} line {issue['range']['start']['line']}")
    assert len(issues) == 0, "All strings should be valid"


@pytest.mark.docfix
def test_doc_pyright_obscured_definitions(pyright_results, capsys):
    "use pyright to check the validity of the generated stubs"
    issues: List[Dict] = pyright_results["generalDiagnostics"]
    # some issues do not have a rule
    issues = [i for i in issues if "rule" in i.keys()]

    # Only look at errors
    issues = list(filter(lambda diag: diag["severity"] == "error", issues))
    issues = list(
        filter(
            lambda diag: diag["rule"] == "reportGeneralTypeIssues" and "is obscured by a declaration" in diag["message"],
            issues,
        )
    )
    for issue in issues:
        print(f"{issue['message']} in {issue['file']} line {issue['range']['start']['line']}")

    assert len(issues) == 0, f"There are {len(issues)} function or class defs that obscure earlier defs"


@pytest.mark.docfix
def test_doc_deepsleep_stub(rst_stubs):
    "Deepsleep stub is generated"
    content = read_stub(rst_stubs / "machine", "__init__.pyi")
    # return type omitted as this is tested separately
    found = any(line.startswith("def deepsleep(time_ms") for line in content)
    assert found, "machine.deepsleep should be stubbed as a function, not as a class - Upstream Docfix needed"


# post version 1.16 documentation has been updated usocket.rst -->socket.rst
@pytest.mark.docfix
def test_doc_socket_class_def(rst_stubs: Path):
    "make sense of `usocket.socket` class documented as a function - Upstream Docfix needed"
    content = read_stub(rst_stubs, "usocket.py")
    if content == []:
        # post version 1.16 documentation has been updated usocket.rst -->socket.rst
        content = read_stub(rst_stubs / "socket", "__init__.pyi")

    found = any(line.startswith("def socket(") for line in content)
    assert not found, "(u)socket.socket should be stubbed as a class, not as a function"

    found = any(line.startswith("class socket") for line in content)
    assert found, "(u)socket.socket classdef should be generated"



@pytest.mark.parametrize(
    "modulename, classname",
    [
        ("select", "poll"),  # compensated
        ("collections", "deque"),
        ("collections", "OrderedDict"),
    ],
)
@pytest.mark.docfix
def test_doc_class_not_function_def(rst_stubs: Path, modulename: str, classname: str):
    "verify `collections.deque` class documented as a function - Upstream Docfix pending"

    content = read_stub(rst_stubs / modulename, "__init__.pyi")
    if content == [] and modulename[0] == "u":
        # module name change to select.py in v1.17+
        modulename = modulename[1:]
        content = read_stub(rst_stubs, modulename)
        if content == []:
            assert f"module {modulename} was not stubbed"
    found = any(line.startswith(f"def {classname}") for line in content)
    # there is actually a poll.poll method ... , so there will be method: def poll ....
    if classname != "poll":
        assert not found, f"class {modulename}.{classname} should not be stubbed as a function"

    found = any(line.startswith(f"class {classname}") for line in content)
    assert found, f"class {modulename}.{classname} must be stubbed as a class"


@pytest.mark.parametrize(
    "error, modulename",
    [
        ("NORMAL is not defined", "pyb"),
        ("UP is not defined", "pyb.Timer"),
        ("hid_mouse is not defined", "pyb.USB_HID"),
        ("SPI is not defined", "lcd160cr"),
    ],
)
@pytest.mark.docfix
def test_doc_CONSTANTS(error, modulename, pyright_results, capsys):
    "use pyright to check the validity of the generated stubs"
    issues: List[Dict] = pyright_results["generalDiagnostics"]
    issues = [i for i in issues if "rule" in i.keys()]  # some reports do not have a rule
    issues = list(
        filter(
            lambda issue: issue["rule"] == "reportUndefinedVariable"
            and issue["message"] == error
            and Path(issue["file"]).stem == modulename,
            issues,
        )
    )
    for issue in issues:
        print(f"{issue['message']} in {issue['file']} line {issue['range']['start']['line']}")
    assert len(issues) == 0
