"""Tests for data_flow.py — entry point, sink, and sanitizer detection.

Each detection function is exercised with focused inline code snippets parsed
by ast.parse(). Tests are data-driven via parametrize to stay concise.
"""

from __future__ import annotations

import ast
from pathlib import Path
from textwrap import dedent

import pytest

from sanicode.scanner.data_flow import (
    detect_entry_points,
    detect_sanitizers,
    detect_sinks,
)

FILE = Path("<test>")


def _parse(source: str) -> ast.Module:
    return ast.parse(dedent(source))


# ---------------------------------------------------------------------------
# Entry point tests
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("source", "expected_name", "expected_kind", "expected_line", "expected_fn"),
    [
        # Flask-style route decorator.
        (
            """\
            @app.route("/login")
            def handle_login():
                pass
            """,
            "handle_login",
            "http_handler",
            2,
            None,  # function IS the entry point; enclosing is None at module scope
        ),
        # FastAPI router decorator.
        (
            """\
            @router.get("/users")
            def list_users():
                pass
            """,
            "list_users",
            "http_handler",
            2,
            None,
        ),
        # 'request' parameter — bare function, no decorator.
        (
            """\
            def view(request):
                pass
            """,
            "view",
            "http_handler",
            1,
            None,
        ),
        # os.environ["KEY"] subscript.
        (
            """\
            def setup():
                val = os.environ["SECRET"]
            """,
            "os.environ",
            "env_var",
            2,
            "setup",
        ),
        # os.getenv call.
        (
            """\
            def load():
                token = os.getenv("TOKEN")
            """,
            "os.getenv",
            "env_var",
            2,
            "load",
        ),
        # os.environ.get call.
        (
            """\
            def load():
                token = os.environ.get("TOKEN")
            """,
            "os.environ.get",
            "env_var",
            2,
            "load",
        ),
        # sys.argv access.
        (
            """\
            def main():
                args = sys.argv
            """,
            "sys.argv",
            "cli_arg",
            2,
            "main",
        ),
        # input() call.
        (
            """\
            def prompt():
                name = input("Enter name: ")
            """,
            "input",
            "stdin",
            2,
            "prompt",
        ),
        # open() in read mode (default).
        (
            """\
            def read_cfg():
                f = open("config.txt")
            """,
            "open",
            "file_read",
            2,
            "read_cfg",
        ),
    ],
)
def test_detect_entry_points(
    source: str,
    expected_name: str,
    expected_kind: str,
    expected_line: int,
    expected_fn: str | None,
) -> None:
    tree = _parse(source)
    results = detect_entry_points(tree, FILE)
    # At least one result of the expected kind/name must exist.
    matches = [r for r in results if r.name == expected_name and r.kind == expected_kind]
    assert matches, (
        f"No EntryPointInfo with name={expected_name!r}, kind={expected_kind!r} in {results}"
    )
    ep = matches[0]
    assert ep.line == expected_line, f"Expected line {expected_line}, got {ep.line}"
    assert ep.function == expected_fn, f"Expected function {expected_fn!r}, got {ep.function!r}"
    assert ep.file == FILE


def test_detect_entry_points_count_flask_route() -> None:
    """A Flask route should produce exactly one entry point."""
    source = """\
        @app.route("/login")
        def handle_login():
            pass
    """
    results = detect_entry_points(_parse(source), FILE)
    http_handlers = [r for r in results if r.kind == "http_handler"]
    assert len(http_handlers) == 1


def test_detect_entry_points_no_false_positives_write() -> None:
    """open() in write mode must NOT be flagged as a file_read entry point."""
    source = """\
        def write_it():
            with open("out.txt", "w") as f:
                f.write("data")
    """
    results = detect_entry_points(_parse(source), FILE)
    file_reads = [r for r in results if r.kind == "file_read"]
    assert not file_reads, f"Unexpected file_read entry points: {file_reads}"


# ---------------------------------------------------------------------------
# Sink tests
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("source", "expected_name", "expected_kind", "expected_cwe", "expected_line", "expected_fn"),
    [
        # eval()
        (
            """\
            def run_code(user_input):
                eval(user_input)
            """,
            "eval",
            "eval",
            94,
            2,
            "run_code",
        ),
        # exec()
        (
            """\
            def execute(code):
                exec(code)
            """,
            "exec",
            "eval",
            94,
            2,
            "execute",
        ),
        # os.system()
        (
            """\
            def run(cmd):
                os.system(cmd)
            """,
            "os.system",
            "command",
            78,
            2,
            "run",
        ),
        # subprocess.call with shell=True
        (
            """\
            def run_shell(cmd):
                subprocess.call(cmd, shell=True)
            """,
            "subprocess.call",
            "command",
            78,
            2,
            "run_shell",
        ),
        # cursor.execute with f-string (SQL injection)
        (
            """\
            def query(table):
                cursor.execute(f"SELECT * FROM {table}")
            """,
            "cursor.execute",
            "sql",
            89,
            2,
            "query",
        ),
        # pickle.loads
        (
            """\
            def load_data(data):
                pickle.loads(data)
            """,
            "pickle.loads",
            "eval",
            502,
            2,
            "load_data",
        ),
        # yaml.load without Loader
        (
            """\
            def load_yaml(data):
                yaml.load(data)
            """,
            "yaml.load",
            "eval",
            502,
            2,
            "load_yaml",
        ),
        # render_template (template injection)
        (
            """\
            def show(val):
                render_template("page.html", user_input=val)
            """,
            "render_template",
            "template",
            79,
            2,
            "show",
        ),
        # requests.get (SSRF sink)
        (
            """\
            def fetch(url):
                requests.get(url)
            """,
            "requests.get",
            "network",
            918,
            2,
            "fetch",
        ),
    ],
)
def test_detect_sinks(
    source: str,
    expected_name: str,
    expected_kind: str,
    expected_cwe: int,
    expected_line: int,
    expected_fn: str | None,
) -> None:
    tree = _parse(source)
    results = detect_sinks(tree, FILE)
    matches = [r for r in results if r.name == expected_name and r.kind == expected_kind]
    assert matches, (
        f"No SinkInfo with name={expected_name!r}, kind={expected_kind!r} in {results}"
    )
    sink = matches[0]
    assert sink.cwe_id == expected_cwe, f"Expected CWE {expected_cwe}, got {sink.cwe_id}"
    assert sink.line == expected_line, f"Expected line {expected_line}, got {sink.line}"
    assert sink.function == expected_fn, f"Expected fn {expected_fn!r}, got {sink.function!r}"
    assert sink.file == FILE


def test_detect_sinks_yaml_load_with_loader_is_safe() -> None:
    """yaml.load(data, Loader=yaml.SafeLoader) must NOT be flagged as a sink."""
    source = """\
        def safe_load(data):
            yaml.load(data, Loader=yaml.SafeLoader)
    """
    results = detect_sinks(_parse(source), FILE)
    yaml_sinks = [r for r in results if r.name == "yaml.load"]
    assert not yaml_sinks, f"yaml.load with Loader should not be a sink: {yaml_sinks}"


def test_detect_sinks_subprocess_without_shell_is_safe() -> None:
    """subprocess.call without shell=True must NOT be flagged."""
    source = """\
        def run(cmd):
            subprocess.call(["ls", "-la"])
    """
    results = detect_sinks(_parse(source), FILE)
    cmd_sinks = [r for r in results if r.kind == "command"]
    assert not cmd_sinks, f"subprocess without shell=True should not be a sink: {cmd_sinks}"


def test_detect_sinks_open_write() -> None:
    """open() in write mode must be identified as a file_write sink."""
    source = """\
        def write_it(path, content):
            with open(path, "w") as f:
                f.write(content)
    """
    results = detect_sinks(_parse(source), FILE)
    file_writes = [r for r in results if r.kind == "file_write"]
    assert file_writes, f"open('w') should produce a file_write sink: {results}"


# ---------------------------------------------------------------------------
# Sanitizer tests
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("source", "expected_name", "expected_kind", "expected_line", "expected_fn"),
    [
        # html.escape
        (
            """\
            def safe_html(text):
                return html.escape(text)
            """,
            "html.escape",
            "html_escape",
            2,
            "safe_html",
        ),
        # shlex.quote
        (
            """\
            def safe_cmd(cmd):
                return shlex.quote(cmd)
            """,
            "shlex.quote",
            "shell_quote",
            2,
            "safe_cmd",
        ),
        # markupsafe.escape
        (
            """\
            def safe_markup(text):
                return markupsafe.escape(text)
            """,
            "markupsafe.escape",
            "html_escape",
            2,
            "safe_markup",
        ),
        # cursor.execute with parameterized tuple
        (
            """\
            def get_user(user_id):
                cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
            """,
            "cursor.execute",
            "parameterized_query",
            2,
            "get_user",
        ),
        # urllib.parse.quote
        (
            """\
            def encode_url(text):
                return urllib.parse.quote(text)
            """,
            "urllib.parse.quote",
            "url_encode",
            2,
            "encode_url",
        ),
    ],
)
def test_detect_sanitizers(
    source: str,
    expected_name: str,
    expected_kind: str,
    expected_line: int,
    expected_fn: str | None,
) -> None:
    tree = _parse(source)
    results = detect_sanitizers(tree, FILE)
    matches = [r for r in results if r.name == expected_name and r.kind == expected_kind]
    assert matches, (
        f"No SanitizerInfo with name={expected_name!r}, kind={expected_kind!r} in {results}"
    )
    san = matches[0]
    assert san.line == expected_line, f"Expected line {expected_line}, got {san.line}"
    assert san.function == expected_fn, f"Expected fn {expected_fn!r}, got {san.function!r}"
    assert san.file == FILE


def test_detect_sanitizers_cursor_execute_no_params_not_sanitizer() -> None:
    """cursor.execute with a raw f-string must NOT be a sanitizer (it's a sink)."""
    source = """\
        def bad_query(table):
            cursor.execute(f"SELECT * FROM {table}")
    """
    results = detect_sanitizers(_parse(source), FILE)
    parameterized = [r for r in results if r.kind == "parameterized_query"]
    assert not parameterized, (
        f"cursor.execute with f-string should not be a sanitizer: {parameterized}"
    )


def test_module_level_entry_point_has_no_enclosing_function() -> None:
    """Entry points at module scope must have function=None."""
    source = "val = os.getenv('HOME')\n"
    results = detect_entry_points(_parse(source), FILE)
    env_vars = [r for r in results if r.kind == "env_var"]
    assert env_vars, "os.getenv at module level should be detected"
    assert env_vars[0].function is None, (
        f"Module-level entry point should have function=None, got {env_vars[0].function!r}"
    )
