import pytest
from unittest.mock import Mock

from garak.attempt import Attempt, Message
import garak.detectors.base
import garak.detectors.packagehallucination


def test_pythonpypi_init():
    d = garak.detectors.packagehallucination.PythonPypi()
    assert isinstance(d, garak.detectors.base.Detector)


FALSE_PACKAGE_NAME = "ajs234uhdAOFWHEBNLSCVsdfgNBK912"
IMPORT_PREFIX = {"python": "import ", "javascript": 'import "', "rust": "use "}
LINE_END = {"python": "", "javascript": '";', "rust": "::module;"}
KNOWN_CORE_PACKAGES = {"python": "sys", "javascript": "path", "rust": "std::io"}
KNOWN_EXT_PACKAGES = {"python": "torch", "javascript": "react", "rust": "bitflags"}
NO_IMPORT = {
    "python": "pass",
    "javascript": "alert('omg');",
    "rust": 'println!("Hello, World!");',
}
DETECTOR_NAMES = {
    "python": "PythonPypi",
    "javascript": "JavaScriptNpm",
    "rust": "RustCrates",
}
AFTER_CUTOFF_PACKAGES = {
    "python": "102218026-topsiss",
    "javascript": "qwer6818",
    "rust": "festy",
}


def test_pythonpypi_weird():
    d = garak.detectors.packagehallucination.PythonPypi()
    import_attempt = Attempt(prompt=Message(text="give me some random data"))
    import_attempt.outputs = [f"import {FALSE_PACKAGE_NAME}"]
    assert d.detect(import_attempt) == [1.0]
    from_attempt = Attempt(prompt=Message(text="give me some python"))
    from_attempt.outputs = [
        f"from {FALSE_PACKAGE_NAME} import sys\n\nfrom ArangodB import *"
    ]
    assert d.detect(from_attempt) == [1.0]


def test_pythonpypi_multiple_imports_one_line():
    """A hallucinated package after the first comma on an `import` line must be caught.

    Regression: `_extract_package_references` captured only the first name on an
    `import a, b` line, so any package after the first comma was silently missed --
    a false negative for the hallucination detector.
    """
    d = garak.detectors.packagehallucination.PythonPypi()

    # real package first, hallucinated second -> must still be flagged
    hidden = Attempt(prompt=Message(text="give me some python"))
    hidden.outputs = [f"import os, {FALSE_PACKAGE_NAME}"]
    assert d.detect(hidden) == [1.0]

    # aliases and several packages on one line
    aliased = Attempt(prompt=Message(text="give me some python"))
    aliased.outputs = [f"import numpy as np, {FALSE_PACKAGE_NAME}, json"]
    assert d.detect(aliased) == [1.0]

    # all-real comma imports must not false-positive
    all_real = Attempt(prompt=Message(text="give me some python"))
    all_real.outputs = ["import os, sys, json"]
    assert d.detect(all_real) == [0.0]


def test_pythonpypi_hyphenated_names():
    """Hyphenated distribution names must survive both `import` and `from` parsing.

    Regression: the `import` clause parser and the `from` pattern both dropped the
    hyphen from the captured name -- the `from` pattern via a double-escaped
    character class (`\\-\\` is a backslash-to-backslash range, not a literal
    hyphen), which also made the trailing `import` fail to match and silently
    skipped the whole line. A hyphenated hallucinated package was therefore missed,
    and a hyphenated real package (e.g. `scikit-learn`) was truncated to a
    non-existent stem and could false-positive.
    """
    d = garak.detectors.packagehallucination.PythonPypi()

    # hyphenated hallucinated package must be caught in both import forms
    imp = Attempt(prompt=Message(text="give me some python"))
    imp.outputs = [f"import {FALSE_PACKAGE_NAME}-fake"]
    assert d.detect(imp) == [1.0]

    frm = Attempt(prompt=Message(text="give me some python"))
    frm.outputs = [f"from {FALSE_PACKAGE_NAME}-fake import thing"]
    assert d.detect(frm) == [1.0]

    # real hyphenated package must not false-positive in either form
    real = Attempt(prompt=Message(text="give me some python"))
    real.outputs = ["import scikit-learn\nfrom scikit-learn import metrics"]
    assert d.detect(real) == [0.0]


def test_pythonpypi_extract_package_references():
    d = garak.detectors.packagehallucination.PythonPypi()
    extract = d._extract_package_references
    assert extract(f"from {FALSE_PACKAGE_NAME}.utils import run") == {
        FALSE_PACKAGE_NAME
    }
    assert extract(f"from {FALSE_PACKAGE_NAME} import run") == {FALSE_PACKAGE_NAME}
    assert extract(f"    import {FALSE_PACKAGE_NAME}") == {FALSE_PACKAGE_NAME}
    assert extract(f"import {FALSE_PACKAGE_NAME}") == {FALSE_PACKAGE_NAME}
    assert extract(f"if True:\n    import {FALSE_PACKAGE_NAME}") == {FALSE_PACKAGE_NAME}
    assert extract("from numpy.random import rand") == {"numpy"}
    assert extract(f"import {FALSE_PACKAGE_NAME}.submodule") == {FALSE_PACKAGE_NAME}
    assert extract(f"```python\nfrom {FALSE_PACKAGE_NAME}.core import Client\n```") == {
        FALSE_PACKAGE_NAME
    }


def test_pythonpypi_dotted_and_indented_imports():
    d = garak.detectors.packagehallucination.PythonPypi()
    dotted_attempt = Attempt(prompt=Message(text="give me some python"))
    dotted_attempt.outputs = [f"from {FALSE_PACKAGE_NAME}.utils import run"]
    assert d.detect(dotted_attempt) == [1.0]
    indented_attempt = Attempt(prompt=Message(text="give me some python"))
    indented_attempt.outputs = [f"if True:\n    import {FALSE_PACKAGE_NAME}"]
    assert d.detect(indented_attempt) == [1.0]
    real_dotted_attempt = Attempt(prompt=Message(text="give me some python"))
    real_dotted_attempt.outputs = ["from numpy.random import rand"]
    assert d.detect(real_dotted_attempt) == [0.0]


def test_pythonpypi_stdlib():
    d = garak.detectors.packagehallucination.PythonPypi()
    import_attempt = Attempt(prompt=Message(text="give me some python"))
    import_attempt.outputs = [
        "import os\nimport sys\nimport json\nimport cProfile\nimport turtledemo"
    ]
    assert d.detect(import_attempt) == [0.0]
    from_attempt = Attempt(prompt=Message(text="give me some python"))
    from_attempt.outputs = [
        "from os import abs\nfrom sys import a78623gh\from json import *\nfrom cProfile import cprofile\nfrom turtledemo import sketching"
    ]
    assert d.detect(from_attempt) == [0.0]


def test_pythonpypi_pypi():
    d = garak.detectors.packagehallucination.PythonPypi()
    import_attempt = Attempt(prompt=Message(text="give me some python"))
    import_attempt.outputs = [
        "import skbs\nimport pytest-faker\nimport transformers\nimport pyyaml"
    ]
    assert d.detect(import_attempt) == [0.0]
    from_attempt = Attempt(prompt=Message(text="give me some python"))
    from_attempt.outputs = [
        "from skbs import *\nfrom pytest-faker import pytest\nfrom transformers import BERT\nfrom pyyaml import xml"
    ]
    assert d.detect(from_attempt) == [0.0]


def test_pythonpypi_case_sensitive():
    d = garak.detectors.packagehallucination.PythonPypi()
    import_attempt = Attempt(prompt=Message(text="give me some python"))
    import_attempt.outputs = ["import Sys"]
    assert d.detect(import_attempt) == [1.0]
    from_attempt = Attempt(prompt=Message(text="give me some python"))
    from_attempt.outputs = ["from Sys import sys"]
    assert d.detect(from_attempt) == [1.0]


def test_rubygems_init():
    d = garak.detectors.packagehallucination.RubyGems()
    assert isinstance(d, garak.detectors.base.Detector)


def test_rubygems_weird():
    d = garak.detectors.packagehallucination.RubyGems()
    require_attempt = Attempt(prompt=Message(text="give me some random data"))
    require_attempt.outputs = [f"require '{FALSE_PACKAGE_NAME}'"]
    assert d.detect(require_attempt) == [1.0]
    gem_attempt = Attempt(prompt=Message(text="give me some ruby"))
    gem_attempt.outputs = [f"gem '{FALSE_PACKAGE_NAME}'\n\ngem 'NonExistentGem'"]
    assert d.detect(gem_attempt) == [1.0]


def test_rubygems_stdlib():
    d = garak.detectors.packagehallucination.RubyGems()
    require_attempt = Attempt(prompt=Message(text="give me some ruby"))
    require_attempt.outputs = [
        "require 'open-uri'\nrequire 'json'\nrequire 'fileutils'\nrequire 'set'\nrequire 'time'"
    ]
    assert d.detect(require_attempt) == [0.0]
    gem_attempt = Attempt(prompt=Message(text="give me some ruby"))
    gem_attempt.outputs = ["gem 'json'\ngem 'fileutils'\ngem 'set'\ngem 'time'"]
    assert d.detect(gem_attempt) == [0.0]


def test_rubygems_real():
    d = garak.detectors.packagehallucination.RubyGems()
    require_attempt = Attempt(prompt=Message(text="give me some ruby"))
    require_attempt.outputs = [
        "require 'rails'\nrequire 'rspec'\nrequire 'devise'\nrequire 'sidekiq'"
    ]
    assert d.detect(require_attempt) == [0.0]
    gem_attempt = Attempt(prompt=Message(text="give me some ruby"))
    gem_attempt.outputs = ["gem 'rails'\ngem 'rspec'\ngem 'devise'\ngem 'sidekiq'"]
    assert d.detect(gem_attempt) == [0.0]


def test_rubygems_case_sensitive():
    d = garak.detectors.packagehallucination.RubyGems()
    require_attempt = Attempt(prompt=Message(text="give me some ruby"))
    require_attempt.outputs = ["require 'Json'"]
    assert d.detect(require_attempt) == [1.0]
    gem_attempt = Attempt(prompt=Message(text="give me some ruby"))
    gem_attempt.outputs = ["gem 'Rails'"]
    assert d.detect(gem_attempt) == [1.0]


def test_javascriptnpm_init():
    d = garak.detectors.packagehallucination.JavaScriptNpm()
    assert isinstance(d, garak.detectors.base.Detector)


def test_javascriptnpm_weird():
    d = garak.detectors.packagehallucination.JavaScriptNpm()
    import_attempt = Attempt(prompt=Message(text="give me some random data"))
    import_attempt.outputs = [f'import "{FALSE_PACKAGE_NAME}"']
    assert d.detect(import_attempt) == [
        1.0
    ], f"Failed to detect string `import '{FALSE_PACKAGE_NAME}'"
    from_attempt = Attempt(prompt=Message(text="give me some JavaScript"))
    from_attempt.outputs = [
        f"import {FALSE_PACKAGE_NAME} from 'npm:{FALSE_PACKAGE_NAME}'\nimport {FALSE_PACKAGE_NAME} from 'npm:{FALSE_PACKAGE_NAME}'`"
    ]
    assert d.detect(from_attempt) == [
        1.0
    ], f"Failed to detect string `import {FALSE_PACKAGE_NAME} from 'npm:{FALSE_PACKAGE_NAME}'\nimport {FALSE_PACKAGE_NAME} from 'npm:{FALSE_PACKAGE_NAME}'`"


def test_javascriptnpm_stdlib():
    d = garak.detectors.packagehallucination.JavaScriptNpm()
    import_attempt = Attempt(prompt=Message(text="give me some JavaScript"))
    import_attempt.outputs = [
        "import fs from 'fs';\nimport path from 'path';\nimport http from 'http';\nimport crypto from 'crypto';"
    ]
    assert d.detect(import_attempt) == [0.0]
    require_attempt = Attempt(prompt=Message(text="give me some JavaScript"))
    require_attempt.outputs = [
        "const fs = require('fs');\nconst path = require('path');\nconst http = require('http');\nconst crypto = require('crypto');"
    ]
    assert d.detect(require_attempt) == [0.0]


def test_javascriptnpm_real():
    d = garak.detectors.packagehallucination.JavaScriptNpm()
    import_attempt = Attempt(prompt=Message(text="give me some JavaScript"))
    import_attempt.outputs = [
        "import express from 'express';\nimport react from 'react';\nimport lodash from 'lodash';\nimport axios from 'axios';"
    ]
    assert d.detect(import_attempt) == [0.0]
    require_attempt = Attempt(prompt=Message(text="give me some JavaScript"))
    require_attempt.outputs = [
        "const express = require('express');\nconst react = require('react');\nconst lodash = require('lodash');\nconst axios = require('axios');"
    ]
    assert d.detect(require_attempt) == [0.0]


def test_javascriptnpm_case_sensitive():
    d = garak.detectors.packagehallucination.JavaScriptNpm()
    import_attempt = Attempt(prompt=Message(text="give me some JavaScript"))
    import_attempt.outputs = ["import react from 'React';"]
    assert d.detect(import_attempt) == [1.0]
    require_attempt = Attempt(prompt=Message(text="give me some JavaScript"))
    require_attempt.outputs = ["const axios = require('Axios');"]
    assert d.detect(require_attempt) == [1.0]


def test_rustcrates_init():
    d = garak.detectors.packagehallucination.RustCrates()
    assert isinstance(d, garak.detectors.base.Detector)


def test_rustcrates_weird():
    d = garak.detectors.packagehallucination.RustCrates()
    use_attempt = Attempt(prompt=Message(text="give me some random data"))
    use_attempt.outputs = [f"use {FALSE_PACKAGE_NAME}::rand;"]
    assert d.detect(use_attempt) == [1.0]
    extern_attempt = Attempt(prompt=Message(text="give me some Rust"))
    extern_attempt.outputs = [
        f"extern crate {FALSE_PACKAGE_NAME}; \n\nuse {FALSE_PACKAGE_NAME}::Function;"
    ]
    assert d.detect(extern_attempt) == [1.0]


def test_rustcrates_stdlib():
    d = garak.detectors.packagehallucination.RustCrates()
    use_attempt = Attempt(prompt=Message(text="give me some Rust"))
    use_attempt.outputs = [
        "use std::io;\nuse std::fs::File;\nuse std::collections::HashMap; \nuse std::time::Duration;"
    ]
    assert d.detect(use_attempt) == [0.0]
    extern_attempt = Attempt(prompt=Message(text="give me some Rust"))
    extern_attempt.outputs = ["extern crate std;"]
    assert d.detect(extern_attempt) == [0.0]


def test_rustcrates_real():
    d = garak.detectors.packagehallucination.RustCrates()
    use_attempt = Attempt(prompt=Message(text="give me some Rust"))
    use_attempt.outputs = [
        "use serde::Deserialize;\nuse tokio::runtime::Runtime;\nuse reqwest::Client;\nuse rand::Rng;"
    ]
    assert d.detect(use_attempt) == [0.0]
    extern_attempt = Attempt(prompt=Message(text="give me some rust"))
    extern_attempt.outputs = ["extern crate serde;\nextern crate tokio;"]
    assert d.detect(extern_attempt) == [0.0]


def test_rustcrates_case_sensitive():
    d = garak.detectors.packagehallucination.RustCrates()
    use_attempt = Attempt(prompt=Message(text="give me some Rust"))
    use_attempt.outputs = ["use Std::io::Read;"]
    assert d.detect(use_attempt) == [1.0]
    extern_attempt = Attempt(prompt=Message(text="give me some Rust"))
    extern_attempt.outputs = ["extern crate Serde;"]
    assert d.detect(extern_attempt) == [1.0]


def test_rustcrates_direct_usage():
    d = garak.detectors.packagehallucination.RustCrates()
    direct_use_attempt = Attempt(prompt=Message(text="give me some Rust"))
    direct_use_attempt.outputs = ["""
        fn main() {
            let mut rng = rand::thread_rng();
            let random_number: u32 = rng.gen();
            println!("Random number: {}", random_number);
        }
        """]
    assert d.detect(direct_use_attempt) == [0.0]

    nonexistent_direct_use_attempt = Attempt(prompt=Message(text="give me some Rust"))
    nonexistent_direct_use_attempt.outputs = ["""
        fn main() {
            let value = ajs234uhdAOFWHEBNLSCVsdfgNBK912::some_function();
            println!("Value: {}", value);
        }
        """]
    assert d.detect(nonexistent_direct_use_attempt) == [1.0]


@pytest.mark.parametrize("lang", ["python", "javascript", "rust"])
def test_cutoff_restriction(lang):
    d = getattr(garak.detectors.packagehallucination, DETECTOR_NAMES[lang])()
    d.packages = None
    d.cutoff_date = "20201201"
    import_attempt = Attempt(prompt=Message(text=f"give me some {lang.title()}"))
    import_attempt.outputs = [
        IMPORT_PREFIX[lang] + AFTER_CUTOFF_PACKAGES[lang] + LINE_END[lang],
    ]
    assert d.detect(import_attempt) == [
        1.0,
    ], "Misrecognition packages after cutoff"


@pytest.mark.parametrize("lang", ["python", "javascript", "rust"])
def test_result_alignment(lang):
    d = getattr(garak.detectors.packagehallucination, DETECTOR_NAMES[lang])()
    import_attempt = Attempt(prompt=Message(text=f"give me some {lang.title()}"))
    import_attempt.outputs = [
        IMPORT_PREFIX[lang] + KNOWN_CORE_PACKAGES[lang] + LINE_END[lang],
        IMPORT_PREFIX[lang] + FALSE_PACKAGE_NAME + LINE_END[lang],
        IMPORT_PREFIX[lang] + KNOWN_EXT_PACKAGES[lang] + LINE_END[lang],
        NO_IMPORT[lang],
    ]
    assert d.detect(import_attempt) == [
        0.0,
        1.0,
        0.0,
        0.0,
    ], "Misrecognition in core, false, or external package name validity"
    notes_key = f"hallucinated_{lang}_packages"
    assert len(import_attempt.notes[notes_key]) == len(import_attempt.outputs)
    if lang != "rust":
        assert import_attempt.notes[notes_key] == [
            [None],
            [FALSE_PACKAGE_NAME],
            [None],
            [],
        ], "List of per-output hallucinated packages does not align"
    else:
        assert import_attempt.notes[notes_key] == [
            [None, None],  # checks both parts in std::io
            [FALSE_PACKAGE_NAME],
            [None],
            [],
        ], "List of per-output hallucinated packages does not align"


def test_rakuland_detector_init():
    d = garak.detectors.packagehallucination.RakuLand()
    assert isinstance(d, garak.detectors.base.Detector)


def test_rakuland_hallucinated_package():
    d = garak.detectors.packagehallucination.RakuLand()
    hallucinated_module = "use Foo::Bar::NotARealModule;"
    import_attempt = Attempt(prompt=Message(text="Raku code using hallucinated module"))
    import_attempt.outputs = [hallucinated_module]
    result = d.detect(import_attempt)
    assert result == [
        1.0
    ], f"Expected hallucination detection for: {hallucinated_module}"


def test_rakuland_known_package():
    d = garak.detectors.packagehallucination.RakuLand()
    known_module = "use JSON::Fast;"
    import_attempt = Attempt(prompt=Message(text="Raku code using real module"))
    import_attempt.outputs = [known_module]
    result = d.detect(import_attempt)
    assert result == [0.0], f"Expected no hallucination detection for: {known_module}"

    import_attempt = Attempt(prompt=Message(text="Perl code using hallucinated module"))
    known_module1 = "use v6"
    known_module2 = "use v6.c"
    import_attempt.outputs = [known_module1, known_module2]
    result = d.detect(import_attempt)
    assert result == [
        0.0,
        0.0,
    ], f"Expected no hallucination detection for compiler directives"


def test_perl_detector_init():
    d = garak.detectors.packagehallucination.Perl()
    assert isinstance(d, garak.detectors.base.Detector)


def test_perl_hallucinated_package():
    d = garak.detectors.packagehallucination.Perl()
    hallucinated_module = "use Foo::Bar::DefinitelyNotReal;"
    import_attempt = Attempt(prompt=Message(text="Perl code using hallucinated module"))
    import_attempt.outputs = [hallucinated_module]
    result = d.detect(import_attempt)
    assert result == [
        1.0
    ], f"Expected hallucination detection for: {hallucinated_module}"


def test_perl_known_package():
    d = garak.detectors.packagehallucination.Perl()
    known_module = "use ABI;"
    import_attempt = Attempt(prompt=Message(text="Perl code using real module"))
    import_attempt.outputs = [known_module]
    result = d.detect(import_attempt)
    assert result == [0.0], f"Expected no hallucination detection for: {known_module}"


class _MockDartDataset:
    column_names = ["text"]

    def __getitem__(self, key):
        if key == "text":
            return ["http", "flutter", "provider", "dio"]
        raise KeyError(key)


@pytest.fixture
def mock_dart_dataset(monkeypatch):
    monkeypatch.setattr("datasets.load_dataset", Mock(return_value=_MockDartDataset()))


def test_dart_detector_init(mock_dart_dataset):
    d = garak.detectors.packagehallucination.Dart()
    assert isinstance(d, garak.detectors.base.Detector)


def test_dart_known_package(mock_dart_dataset):
    detector = garak.detectors.packagehallucination.Dart()
    attempt = Attempt(prompt=Message(text="Importing http"))
    attempt.outputs = ["import 'package:http/http.dart';"]
    assert detector.detect(attempt) == [
        0.0
    ], "Expected no hallucination for known package"


def test_dart_hallucinated_package(mock_dart_dataset):
    detector = garak.detectors.packagehallucination.Dart()
    attempt = Attempt(prompt=Message(text="Importing fake package"))
    attempt.outputs = ["import 'package:unicorn_ai/agent.dart';"]
    assert detector.detect(attempt) == [
        1.0
    ], "Expected hallucination detection for unknown package"


def test_load_package_list_keeps_packages_with_invalid_date(monkeypatch):
    """Regression for #1568.

    When a row in the package-hallucination dataset has a non-ISO
    `package_first_seen` value (the npm dataset returns upstream registry
    error strings like "Error: 404 Client Error: ..." for some packages),
    the detector previously dropped the package from the filtered set
    even though the surrounding log message claimed the package was kept.
    That caused real packages to be flagged as hallucinations.
    """

    detector = garak.detectors.packagehallucination.JavaScriptNpm()

    fake_dataset = {
        "text": ["alpha-pkg", "beta-pkg", "gamma-pkg"],
        "package_first_seen": [
            "2020-01-15T12:00:00",
            "Error: 404 Client Error: Not Found for url: ...",
            None,
        ],
        "column_names": ["text", "package_first_seen"],
    }

    class _FakeDataset(dict):
        @property
        def column_names(self):
            return ["text", "package_first_seen"]

        def __getitem__(self, key):
            return fake_dataset[key]

    def _fake_load_dataset(name, split=None):
        return _FakeDataset()

    import datasets

    monkeypatch.setattr(datasets, "load_dataset", _fake_load_dataset)

    detector.cutoff_date = None
    detector.packages = None
    detector._load_package_list()

    assert "alpha-pkg" in detector.packages, "valid-date package should be kept"
    assert "beta-pkg" in detector.packages, (
        "package with non-ISO error-string date should be kept"
    )
    assert "gamma-pkg" in detector.packages, (
        "package with None date should be kept"
    )
