# Copyright (c) 2025, NVIDIA CORPORATION.  All rights reserved.
#
# 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.

# pyright: reportMissingImports=false
# pylint: disable=import-error,redefined-outer-name


import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Tuple

import pytest
import soundfile as sf
import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader, Dataset

from nemo.collections.asr.parts.mixins.diarization import DiarizeConfig, SpkDiarizationMixin


class DummyModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.encoder = torch.nn.Linear(1, 1)
        # Make the test deterministic and ensure positive outputs.
        with torch.no_grad():
            self.encoder.weight.fill_(1.0)
            self.encoder.bias.fill_(1.0)

        self.execution_count = 0
        self.flag_begin = False

    def forward(self, x):
        # Input: [1, 1] Output = [1, 1
        out = self.encoder(x)
        return out


class AudioPathDataset(Dataset):
    def __init__(self, audio_filepaths: List[str]):
        self._audio_filepaths = audio_filepaths

    def __len__(self) -> int:
        return len(self._audio_filepaths)

    def __getitem__(self, index: int) -> Tuple[torch.Tensor, torch.Tensor]:
        samples, _ = sf.read(self._audio_filepaths[index], dtype="float32", always_2d=False)
        if hasattr(samples, "ndim") and samples.ndim == 2:
            samples = samples.mean(axis=1)
        waveform = torch.as_tensor(samples, dtype=torch.float32)
        length = torch.tensor(waveform.shape[0], dtype=torch.long)
        return waveform, length


def collate(batch):
    waveforms, lengths = zip(*batch)
    padded = pad_sequence(waveforms, batch_first=True)  # (B, T)
    lengths_tensor = torch.stack(lengths, dim=0)
    return padded, lengths_tensor


@pytest.fixture()
def audio_files(test_data_dir):
    """
    Returns audio arrays + sample rate + filepaths for testing.
    """

    audio_file1 = os.path.join(test_data_dir, "an4_speaker", "an4", "wav", "an4_clstk", "fash", "an251-fash-b.wav")
    audio_file2 = os.path.join(test_data_dir, "an4_speaker", "an4", "wav", "an4_clstk", "ffmm", "cen1-ffmm-b.wav")

    audio1, sample_rate1 = sf.read(audio_file1, dtype='float32', always_2d=False)
    audio2, sample_rate2 = sf.read(audio_file2, dtype='float32', always_2d=False)
    assert int(sample_rate1) == int(sample_rate2)

    return audio1, audio2, int(sample_rate1), audio_file1, audio_file2


class DiarizableDummy(DummyModel, SpkDiarizationMixin):
    def _diarize_on_begin(self, audio, diarcfg: DiarizeConfig):
        super()._diarize_on_begin(audio, diarcfg)
        self.flag_begin = True

    def _setup_diarize_dataloader(self, config: Dict) -> DataLoader:
        if "manifest_filepath" in config:
            filepaths: List[str] = []
            with open(config["manifest_filepath"], "r", encoding="utf-8") as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    filepaths.append(json.loads(line)["audio_filepath"])
        else:
            filepaths = list(config["paths2audio_files"])

        return DataLoader(
            dataset=AudioPathDataset(filepaths),
            batch_size=int(config.get("batch_size", 1)),
            num_workers=int(config.get("num_workers", 0)),
            pin_memory=False,
            drop_last=False,
            collate_fn=collate,
        )

    def _diarize_forward(self, batch: Any):
        """
        Real inference step for diarization tests.

        The dataloader yields `(padded_waveforms, lengths)` where:
        - padded_waveforms: (B, T)
        - lengths: (B,)

        We compute a masked mean per sample -> (B, 1) and run it through the model.
        """
        if not isinstance(batch, (tuple, list)) or len(batch) != 2:
            raise TypeError(f"Expected batch=(waveforms, lengths), got: {type(batch)}")

        waveforms, lengths = batch
        if waveforms.dim() != 2:
            raise ValueError(f"Expected waveforms of shape (B, T), got {tuple(waveforms.shape)}")
        if lengths.dim() != 1:
            raise ValueError(f"Expected lengths of shape (B,), got {tuple(lengths.shape)}")

        # Masked mean pooling over time dimension.
        _, T = waveforms.shape
        device = waveforms.device
        t = torch.arange(T, device=device).unsqueeze(0)  # (1, T)
        mask = (t < lengths.unsqueeze(1)).to(waveforms.dtype)  # (B, T)
        denom = lengths.to(waveforms.dtype).clamp_min(1.0).unsqueeze(1)  # (B, 1)
        pooled = (waveforms * mask).sum(dim=1, keepdim=True) / denom  # (B, 1)

        preds = self(pooled)  # (B, 1)
        return preds

    def _diarize_output_processing(self, outputs, uniq_ids, diarcfg: DiarizeConfig):
        self.execution_count += 1
        # Ensure "one scalar per input sample".
        outputs = outputs.detach().cpu().view(outputs.shape[0], -1).mean(dim=1)
        return [float(x) for x in outputs]


@pytest.fixture()
def dummy_model():
    return DiarizableDummy()


class TestSpkDiarizationMixin:
    pytestmark = pytest.mark.with_downloads()

    @pytest.mark.unit
    def test_constructor_non_instance(self):
        model = DummyModel()
        assert not isinstance(model, SpkDiarizationMixin)
        assert not hasattr(model, 'diarize')

    @pytest.mark.unit
    def test_diarize_wav_path_single(self, dummy_model, audio_files):
        dummy_model = dummy_model.eval()
        _, _, _, audio_file1, _ = audio_files
        outputs = dummy_model.diarize(audio_file1, batch_size=1)
        assert isinstance(outputs, list)
        assert len(outputs) == 1
        assert outputs[0] > 0

    @pytest.mark.unit
    def test_diarize_wav_path_list(self, dummy_model, audio_files):
        dummy_model = dummy_model.eval()
        _, _, _, audio_file1, audio_file2 = audio_files
        outputs = dummy_model.diarize([audio_file1, audio_file2], batch_size=1)
        assert isinstance(outputs, list)
        assert len(outputs) == 2
        assert outputs[0] > 0
        assert outputs[1] > 0

    @pytest.mark.unit
    def test_diarize_manifest_jsonl_path(self, dummy_model, audio_files, tmp_path: Path):
        dummy_model = dummy_model.eval()
        _, _, _, audio_file1, audio_file2 = audio_files
        manifest_path = tmp_path / "manifest.jsonl"
        with manifest_path.open("w", encoding="utf-8") as f:
            for audio_file in (audio_file1, audio_file2):
                f.write(json.dumps({"audio_filepath": audio_file, "offset": 0, "duration": None, "text": "-"}) + "\n")
        outputs = dummy_model.diarize(str(manifest_path), batch_size=1)
        assert isinstance(outputs, list)
        assert len(outputs) == 2

    @pytest.mark.unit
    def test_diarize_numpy_single_requires_sample_rate(self, dummy_model, audio_files):
        dummy_model = dummy_model.eval()
        audio1, _, _, _, _ = audio_files

        # Check if it raises an error without sample rate when using a single numpy variable input
        with pytest.raises(ValueError):
            _ = dummy_model.diarize(audio=audio1, batch_size=1)

        # Set sample rate and check if it works
        sample_rate = 16000
        outputs = dummy_model.diarize(audio1, batch_size=1, sample_rate=sample_rate)
        assert isinstance(outputs, list)
        assert len(outputs) == 1
        assert outputs[0] > 0

    @pytest.mark.unit
    def test_diarize_numpy_list_requires_sample_rate(self, dummy_model, audio_files):
        dummy_model = dummy_model.eval()
        audio1, audio2, _, _, _ = audio_files
        numpy_audio_list = [audio1, audio2]
        # Check if it raises an error without sample rate when using numpy list input
        with pytest.raises(ValueError):
            _ = dummy_model.diarize(audio=numpy_audio_list, batch_size=2)

        # Set sample rate and check if it works
        sample_rate = 16000
        outputs = dummy_model.diarize(audio=numpy_audio_list, batch_size=2, sample_rate=sample_rate)
        assert isinstance(outputs, list)
        assert len(outputs) == 2
        assert outputs[0] > 0
        assert outputs[1] > 0

    @pytest.mark.unit
    def test_diarize_numpy_single(self, dummy_model, audio_files):
        dummy_model = dummy_model.eval()
        audio1, _, sample_rate, _, _ = audio_files
        outputs = dummy_model.diarize(audio1, batch_size=1, sample_rate=sample_rate)
        assert isinstance(outputs, list)
        assert len(outputs) == 1
        assert outputs[0] > 0

    @pytest.mark.unit
    def test_diarize_numpy_list(self, dummy_model, audio_files):
        dummy_model = dummy_model.eval()
        audio1, audio2, sample_rate, _, _ = audio_files
        outputs = dummy_model.diarize([audio1, audio2], batch_size=1, sample_rate=sample_rate)
        assert isinstance(outputs, list)
        assert len(outputs) == 2
        assert outputs[0] > 0
        assert outputs[1] > 0

    @pytest.mark.unit
    def test_diarize_numpy_list_but_no_sample_rate(self, dummy_model, audio_files):
        dummy_model = dummy_model.eval()
        # Numpy audio inputs require sample_rate; the mixin should raise with a clear message.
        with pytest.raises(
            ValueError, match=r"Sample rate is not set\. Numpy audio inputs require sample_rate to be set\."
        ):
            _ = dummy_model.diarize(audio_files, batch_size=1)

    @pytest.mark.unit
    def test_transribe_override_config_incorrect(self, dummy_model, audio_files):
        # Not subclassing DiarizeConfig
        @dataclass
        class OverrideConfig:
            batch_size: int = 1
            output_type: str = 'dict'

        dummy_model = dummy_model.eval()

        audio1, _, _, _, _ = audio_files
        override_cfg = OverrideConfig(batch_size=1, output_type='dict')
        with pytest.raises(ValueError):
            _ = dummy_model.diarize(audio1, override_config=override_cfg)

    @pytest.mark.unit
    def test_transribe_override_config_correct(self, dummy_model, audio_files):
        @dataclass
        class OverrideConfig(DiarizeConfig):
            output_type: str = 'dict'
            verbose: bool = False

        dummy_model = dummy_model.eval()
        audio1, _, sample_rate, _, _ = audio_files
        override_cfg = OverrideConfig(batch_size=1, output_type='list', sample_rate=sample_rate)
        outputs = dummy_model.diarize(audio1, override_config=override_cfg)

        assert isinstance(outputs, list)
        assert len(outputs) == 1
