from __future__ import annotations

import json
import sys
import types
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List

from behave import given, then, when

from features.environment import run_biblicus


@dataclass
class _FakeBerTopicBehavior:
    topic_assignments: List[int]
    topic_keywords: Dict[int, List[tuple[str, float]]]
    last_documents: List[str]


@dataclass
class _FakeSpacyBehavior:
    entities: List[tuple[str, str]]


def _corpus_path(context, name: str) -> Path:
    return (context.workdir / name).resolve()


def _parse_json_output(standard_output: str) -> dict[str, object]:
    return json.loads(standard_output)



def _ensure_fake_bertopic_behavior(context) -> _FakeBerTopicBehavior:
    behavior = getattr(context, "fake_bertopic_behavior", None)
    if behavior is None:
        behavior = _FakeBerTopicBehavior(
            topic_assignments=[],
            topic_keywords={},
            last_documents=[],
        )
        context.fake_bertopic_behavior = behavior
    return behavior


def _ensure_fake_spacy_behavior(context) -> _FakeSpacyBehavior:
    behavior = getattr(context, "fake_spacy_behavior", None)
    if behavior is None:
        behavior = _FakeSpacyBehavior(entities=[])
        context.fake_spacy_behavior = behavior
    return behavior


def _install_fake_bertopic_module(context, *, use_fake_marker: bool) -> None:
    already_installed = getattr(context, "_fake_bertopic_installed", False)
    if already_installed:
        return

    original_modules: Dict[str, object] = {}
    if "bertopic" in sys.modules:
        original_modules["bertopic"] = sys.modules["bertopic"]

    behavior = _ensure_fake_bertopic_behavior(context)

    class BERTopic:  # noqa: N801 - external dependency uses PascalCase
        def __init__(self, **kwargs):  # type: ignore[no-untyped-def]
            self._kwargs = dict(kwargs)
            self._assignments: List[int] = []
            self._documents: List[str] = []

        def fit_transform(self, documents):  # type: ignore[no-untyped-def]
            self._documents = [str(doc) for doc in documents]
            behavior.last_documents = list(self._documents)
            assignments = list(behavior.topic_assignments)
            if not assignments:
                assignments = [0 for _ in self._documents]
            if len(assignments) < len(self._documents):
                expanded: List[int] = []
                for idx, _ in enumerate(self._documents):
                    expanded.append(assignments[idx % len(assignments)])
                assignments = expanded
            self._assignments = assignments
            return assignments, None

        def get_topic_info(self):  # type: ignore[no-untyped-def]
            counts = Counter(self._assignments)
            records = []
            for topic_id in sorted(counts.keys()):
                keywords = behavior.topic_keywords.get(topic_id, [])
                name = keywords[0][0] if keywords else f"Topic {topic_id}"
                records.append({"Topic": topic_id, "Count": counts[topic_id], "Name": name})
            return records

        def get_topic(self, topic_id: int):  # type: ignore[no-untyped-def]
            return behavior.topic_keywords.get(topic_id, [])

    bertopic_module = types.ModuleType("bertopic")
    bertopic_module.BERTopic = BERTopic
    if use_fake_marker:
        bertopic_module.__biblicus_fake__ = True

    sys.modules["bertopic"] = bertopic_module

    context._fake_bertopic_installed = True
    context._fake_bertopic_original_modules = original_modules


def _install_fake_spacy_module(context) -> None:
    already_installed = getattr(context, "_fake_spacy_installed", False)
    if already_installed:
        return

    original_modules: Dict[str, object] = {}
    if "spacy" in sys.modules:
        original_modules["spacy"] = sys.modules["spacy"]

    behavior = _ensure_fake_spacy_behavior(context)

    class _FakeEntity:
        def __init__(self, start_char: int, end_char: int, label: str) -> None:
            self.start_char = start_char
            self.end_char = end_char
            self.label_ = label

    class _FakeDoc:
        def __init__(self, text: str, ents: List[_FakeEntity]) -> None:
            self.text = text
            self.ents = ents

    class _FakeNlp:
        def __call__(self, text: str) -> _FakeDoc:  # type: ignore[no-untyped-def]
            ents: List[_FakeEntity] = []
            for phrase, label in behavior.entities:
                if not phrase:
                    continue
                start = 0
                while True:
                    index = text.find(phrase, start)
                    if index == -1:
                        break
                    ents.append(_FakeEntity(index, index + len(phrase), label))
                    start = index + len(phrase)
            ents.sort(key=lambda ent: ent.start_char)
            return _FakeDoc(text, ents)

    def load(model_name: str) -> _FakeNlp:  # type: ignore[no-untyped-def]
        return _FakeNlp()

    spacy_module = types.ModuleType("spacy")
    spacy_module.load = load
    sys.modules["spacy"] = spacy_module

    context._fake_spacy_installed = True
    context._fake_spacy_original_modules = original_modules


def _install_spacy_unavailable_module(context) -> None:
    original_modules: Dict[str, object] = {}
    if "spacy" in sys.modules:
        original_modules["spacy"] = sys.modules["spacy"]

    def load(*_args, **_kwargs):  # type: ignore[no-untyped-def]
        raise ImportError("spaCy unavailable")

    spacy_module = types.ModuleType("spacy")
    spacy_module.load = load
    sys.modules["spacy"] = spacy_module

    context._fake_spacy_unavailable_installed = True
    context._fake_spacy_unavailable_original_modules = original_modules


def _install_bertopic_unavailable_module(context) -> None:
    already_installed = getattr(context, "_fake_bertopic_unavailable_installed", False)
    if already_installed:
        return

    original_modules: Dict[str, object] = {}
    if "bertopic" in sys.modules:
        original_modules["bertopic"] = sys.modules["bertopic"]

    bertopic_module = types.ModuleType("bertopic")
    sys.modules["bertopic"] = bertopic_module

    context._fake_bertopic_unavailable_installed = True
    context._fake_bertopic_unavailable_original_modules = original_modules


def _snapshot_reference_from_context(context) -> str:
    extractor_id = context.last_extractor_id
    snapshot_id = context.last_extraction_snapshot_id
    assert isinstance(extractor_id, str) and extractor_id
    assert isinstance(snapshot_id, str) and snapshot_id
    return f"{extractor_id}:{snapshot_id}"


@given("a fake BERTopic library is available")
def step_fake_bertopic_available(context) -> None:
    _install_fake_bertopic_module(context, use_fake_marker=True)


@given('a fake BERTopic library is available with topic assignments "{assignments}" and keywords:')
def step_fake_bertopic_with_assignments(context, assignments: str) -> None:
    _install_fake_bertopic_module(context, use_fake_marker=True)
    behavior = _ensure_fake_bertopic_behavior(context)
    parsed_assignments: List[int] = []
    for token in assignments.split(","):
        token = token.strip()
        if token:
            parsed_assignments.append(int(token))
    behavior.topic_assignments = parsed_assignments
    topic_keywords: Dict[int, List[tuple[str, float]]] = {}
    for row in context.table:
        topic_id = int(row["topic_id"].strip())
        raw_keywords = row["keywords"].strip()
        keywords = [keyword.strip() for keyword in raw_keywords.split(",") if keyword.strip()]
        scored = [(keyword, 1.0 - (index * 0.1)) for index, keyword in enumerate(keywords)]
        topic_keywords[topic_id] = scored
    behavior.topic_keywords = topic_keywords


@given("the BERTopic dependency is unavailable")
def step_bertopic_dependency_unavailable(context) -> None:
    _install_bertopic_unavailable_module(context)


@given("a fake spaCy library is available with entities:")
def step_fake_spacy_with_entities(context) -> None:
    _install_fake_spacy_module(context)
    behavior = _ensure_fake_spacy_behavior(context)
    behavior.entities = []
    for row in context.table or []:
        phrase = str(row.get("text") or "").strip()
        label = str(row.get("label") or "").strip()
        if phrase and label:
            behavior.entities.append((phrase, label))


@given("the spaCy dependency is unavailable")
@when("the spaCy dependency is unavailable")
def step_spacy_dependency_unavailable(context) -> None:
    _install_spacy_unavailable_module(context)


def _install_fake_sklearn_module(context) -> None:
    already_installed = getattr(context, "_fake_sklearn_installed", False)
    if already_installed:
        return

    original_modules: Dict[str, object] = {}
    for name in [
        "sklearn",
        "sklearn.feature_extraction",
        "sklearn.feature_extraction.text",
    ]:
        if name in sys.modules:
            original_modules[name] = sys.modules[name]

    class CountVectorizer:  # noqa: N801 - external dependency uses PascalCase
        def __init__(self, **kwargs):  # type: ignore[no-untyped-def]
            self.kwargs = dict(kwargs)

    sklearn_module = types.ModuleType("sklearn")
    feature_extraction = types.ModuleType("sklearn.feature_extraction")
    feature_text = types.ModuleType("sklearn.feature_extraction.text")
    feature_text.CountVectorizer = CountVectorizer
    feature_extraction.text = feature_text
    sklearn_module.feature_extraction = feature_extraction

    sys.modules["sklearn"] = sklearn_module
    sys.modules["sklearn.feature_extraction"] = feature_extraction
    sys.modules["sklearn.feature_extraction.text"] = feature_text

    context._fake_sklearn_installed = True
    context._fake_sklearn_original_modules = original_modules


def _install_sklearn_unavailable_module(context) -> None:
    already_installed = getattr(context, "_fake_sklearn_unavailable_installed", False)
    if already_installed:
        return

    original_modules: Dict[str, object] = {}
    for name in [
        "sklearn",
        "sklearn.feature_extraction",
        "sklearn.feature_extraction.text",
    ]:
        if name in sys.modules:
            original_modules[name] = sys.modules[name]
            sys.modules.pop(name, None)

    sklearn_module = types.ModuleType("sklearn")
    sys.modules["sklearn"] = sklearn_module

    context._fake_sklearn_unavailable_installed = True
    context._fake_sklearn_unavailable_original_modules = original_modules


@given("a fake BERTopic library without a fake marker is available")
def step_fake_bertopic_without_marker(context) -> None:
    _install_fake_bertopic_module(context, use_fake_marker=False)
    _install_fake_sklearn_module(context)


@given("the scikit-learn dependency is unavailable")
def step_sklearn_dependency_unavailable(context) -> None:
    _install_sklearn_unavailable_module(context)


@when(
    'I snapshot a topic analysis in corpus "{corpus_name}" using configuration "{configuration_file}" '
    "and the latest extraction snapshot"
)
def step_run_topic_analysis_with_latest_extraction(
    context, corpus_name: str, configuration_file: str
) -> None:
    corpus = _corpus_path(context, corpus_name)
    workdir = getattr(context, "workdir", None)
    assert workdir is not None
    configuration_path = Path(workdir) / configuration_file
    snapshot_ref = _snapshot_reference_from_context(context)
    args = [
        "--corpus",
        str(corpus),
        "analyze",
        "topics",
        "--configuration",
        str(configuration_path),
        "--extraction-snapshot",
        snapshot_ref,
    ]
    result = run_biblicus(context, args, extra_env=getattr(context, "extra_env", None))
    context.last_result = result
    if result.returncode == 0:
        context.last_analysis_output = _parse_json_output(result.stdout)


@when(
    'I snapshot a topic analysis in corpus "{corpus_name}" using configuration "{configuration_file}"'
)
def step_run_topic_analysis_with_configuration(
    context, corpus_name: str, configuration_file: str
) -> None:
    corpus = _corpus_path(context, corpus_name)
    workdir = getattr(context, "workdir", None)
    assert workdir is not None
    configuration_path = Path(workdir) / configuration_file
    args = [
        "--corpus",
        str(corpus),
        "analyze",
        "topics",
        "--configuration",
        str(configuration_path),
    ]
    result = run_biblicus(context, args, extra_env=getattr(context, "extra_env", None))
    context.last_result = result
    if result.returncode == 0:
        context.last_analysis_output = _parse_json_output(result.stdout)


@when(
    'I snapshot a topic analysis in corpus "{corpus_name}" using configurations "{configuration_files}" '
    "and the latest extraction snapshot with config overrides:"
)
def step_run_topic_analysis_with_recipes_and_overrides(
    context, corpus_name: str, configuration_files: str
) -> None:
    corpus = _corpus_path(context, corpus_name)
    workdir = getattr(context, "workdir", None)
    assert workdir is not None
    configuration_paths = []
    for token in configuration_files.split(","):
        token = token.strip()
        if not token:
            continue
        configuration_paths.append(str(Path(workdir) / token))
    if not configuration_paths:
        raise AssertionError("Expected at least one configuration file")

    snapshot_ref = _snapshot_reference_from_context(context)
    args = ["--corpus", str(corpus), "analyze", "topics"]
    for configuration_path in configuration_paths:
        args.extend(["--configuration", configuration_path])
    for row in context.table:
        key = str(row["key"]).strip()
        value = str(row["value"]).strip()
        args.extend(["--override", f"{key}={value}"])
    args.extend(["--extraction-snapshot", snapshot_ref])
    result = run_biblicus(context, args, extra_env=getattr(context, "extra_env", None))
    context.last_result = result
    if result.returncode == 0:
        context.last_analysis_output = _parse_json_output(result.stdout)


@then('the topic analysis report includes BERTopic parameter "{key}" with value {value:d}')
def step_topic_analysis_report_includes_bertopic_parameter_int(
    context, key: str, value: int
) -> None:
    output = context.last_analysis_output
    report = output["report"]["bertopic_analysis"]
    parameters = report.get("parameters")
    assert isinstance(parameters, dict)
    assert parameters.get(key) == value


@then("the topic analysis output includes {count:d} topics")
def step_topic_analysis_output_includes_topic_count(context, count: int) -> None:
    output = context.last_analysis_output
    topics = output["report"]["topics"]
    assert len(topics) == count


@then("the topic analysis output includes topic labels:")
def step_topic_analysis_output_includes_labels(context) -> None:
    output = context.last_analysis_output
    topics = output["report"]["topics"]
    labels = {topic["label"] for topic in topics}
    expected = {row["label"].strip() for row in context.table}
    assert labels == expected


@then('the BERTopic input documents do not include "{text}"')
def step_bertopic_input_documents_do_not_include(context, text: str) -> None:
    behavior = _ensure_fake_bertopic_behavior(context)
    assert all(text not in doc for doc in behavior.last_documents), behavior.last_documents


@then('the analysis manifest includes artifact path "{artifact_path}"')
def step_analysis_manifest_includes_artifact_path(context, artifact_path: str) -> None:
    output = context.last_analysis_output
    manifest = output["snapshot"]
    artifact_paths = manifest.get("artifact_paths", [])
    assert artifact_path in artifact_paths


@then('the topic analysis output includes topic label "{label}"')
def step_topic_analysis_output_includes_label(context, label: str) -> None:
    output = context.last_analysis_output
    topics = output["report"]["topics"]
    labels = {topic["label"] for topic in topics}
    assert label in labels


@then("the BERTopic analysis report includes ngram range {min_value:d} and {max_value:d}")
def step_bertopic_report_includes_ngram_range(context, min_value: int, max_value: int) -> None:
    output = context.last_analysis_output
    report = output["report"]["bertopic_analysis"]
    vectorizer = report.get("vectorizer")
    assert isinstance(vectorizer, dict)
    ngram_range = vectorizer.get("ngram_range")
    assert ngram_range == [min_value, max_value]


@then('the BERTopic analysis report includes stop words "{stop_words}"')
def step_bertopic_report_includes_stop_words(context, stop_words: str) -> None:
    output = context.last_analysis_output
    report = output["report"]["bertopic_analysis"]
    vectorizer = report.get("vectorizer")
    assert isinstance(vectorizer, dict)
    value = vectorizer.get("stop_words")
    assert value == stop_words


@then('the topic analysis output label source is "{source}"')
def step_topic_analysis_output_label_source(context, source: str) -> None:
    output = context.last_analysis_output
    topics = output["report"]["topics"]
    assert topics
    sources = {topic["label_source"] for topic in topics}
    assert sources == {source}


@then("the topic analysis output llm extraction output documents equals {count:d}")
def step_topic_analysis_output_llm_documents(context, count: int) -> None:
    if not hasattr(context, "last_analysis_output"):
        result = getattr(context, "last_result", None)
        stderr = getattr(result, "stderr", "") if result is not None else ""
        raise AssertionError(f"Topic analysis output missing. stderr: {stderr}")
    output = context.last_analysis_output
    report = output["report"]["llm_extraction"]
    assert report is not None
    assert report["output_documents"] == count


@then("the topic analysis output uses the latest extraction snapshot reference")
def step_topic_analysis_output_uses_latest_extraction(context) -> None:
    output = context.last_analysis_output
    run_input = output["snapshot"]["input"]
    extraction_snapshot = run_input["extraction_snapshot"]
    assert extraction_snapshot["extractor_id"] == context.last_extractor_id
    assert extraction_snapshot["snapshot_id"] == context.last_extraction_snapshot_id
