
"""
Tests for the MsaStatsCalculator Python implementation.
"""
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent))

import pytest
from pathlib import Path

from src.msa_stats_calculator import MsaStatsCalculator, MsaStatsError, StatType

# The ground truth values are taken directly from the C++ test `stats_calc_test.cpp`
GROUND_TRUTH_STATS = [
    (StatType.AVG_GAP_SIZE, pytest.approx(2.71, 1e-2)),
    (StatType.MSA_LEN, 37),
    (StatType.LONGEST_UNALIGNED_SEQ, 28),
    (StatType.SHORTEST_UNALIGNED_SEQ, 15),
    (StatType.TOT_NUM_GAPS, 24),
    (StatType.NUM_GAPS_LEN_ONE, 6),
    (StatType.NUM_GAPS_LEN_TWO, 6),
    (StatType.NUM_GAPS_LEN_THREE, 6),
    (StatType.NUM_GAPS_LEN_AT_LEAST_FOUR, 6),
    (StatType.AVG_UNIQUE_GAP_SIZE, pytest.approx(2.67, 1e-2)),
    (StatType.TOT_NUM_UNIQUE_GAPS, 12),
    (StatType.NUM_GAPS_LEN_ONE_IN_ONE_SEQ, 1),
    (StatType.NUM_GAPS_LEN_ONE_IN_TWO_SEQS, 1),
    (StatType.NUM_GAPS_LEN_ONE_IN_ALL_EXCEPT_ONE, 1),
    (StatType.NUM_GAPS_LEN_TWO_IN_ONE_SEQ, 1),
    (StatType.NUM_GAPS_LEN_TWO_IN_TWO_SEQS, 1),
    (StatType.NUM_GAPS_LEN_TWO_IN_ALL_EXCEPT_ONE, 1),
    (StatType.NUM_GAPS_LEN_THREE_IN_ONE_SEQ, 1),
    (StatType.NUM_GAPS_LEN_THREE_IN_TWO_SEQS, 1),
    (StatType.NUM_GAPS_LEN_THREE_IN_ALL_EXCEPT_ONE, 1),
    (StatType.NUM_GAPS_LEN_AT_LEAST_FOUR_IN_ONE_SEQ, 1),
    (StatType.NUM_GAPS_LEN_AT_LEAST_FOUR_IN_TWO_SEQS, 1),
    (StatType.NUM_GAPS_LEN_AT_LEAST_FOUR_IN_ALL_EXCEPT_ONE, 1),
    (StatType.MSA_POSITION_WITH_0_GAPS, 10),
    (StatType.MSA_POSITION_WITH_1_GAPS, 2),
    (StatType.MSA_POSITION_WITH_2_GAPS, 12),
    (StatType.MSA_POSITION_WITH_N_MINUS_1_GAPS, 13),
]

@pytest.fixture(scope="module")
def calculated_msa() -> MsaStatsCalculator:
    """
    Fixture that provides a fully calculated MsaStatsCalculator instance
    based on the dummy FASTA file.
    """
    # Correct the path to be relative to the project root
    fasta_path = Path(__file__).parent.parent / "tests" / "msas" / "dummy_msa.fasta"
    calculator = MsaStatsCalculator.from_fasta(fasta_path)
    calculator.recompute_stats()
    return calculator

class TestMsaStatsCalculator:
    """Test suite for the MsaStatsCalculator."""

    @pytest.mark.parametrize("stat_type, expected_value", GROUND_TRUTH_STATS)
    def test_all_statistics_from_fasta(self, calculated_msa: MsaStatsCalculator, stat_type: StatType, expected_value):
        """
        Tests that every calculated statistic matches the ground truth from the C++ tests.
        """
        assert calculated_msa.get_stat_by_type(stat_type) == expected_value

    def test_constructor_with_list(self):
        """
        Tests that the calculator can be initialized directly with a list of strings
        and produces correct results.
        """
        sequences = [
            "AC--GT",
            "ACGTGT",
            "AC-TGT",
        ]
        calculator = MsaStatsCalculator(sequences)
        calculator.recompute_stats()

        assert calculator.get_stat_by_type(StatType.TOT_NUM_GAPS) == 2
        assert calculator.get_stat_by_type(StatType.MSA_LEN) == 6
        assert calculator.get_stat_by_type(StatType.LONGEST_UNALIGNED_SEQ) == 6
        assert calculator.get_stat_by_type(StatType.SHORTEST_UNALIGNED_SEQ) == 4

    def test_string_representation(self, calculated_msa: MsaStatsCalculator):
        """
        Tests the __str__ method for a correct representation of the MSA.
        """
        fasta_path = Path(__file__).parent.parent / "tests" / "msas" / "dummy_msa.fasta"
        with open(fasta_path, 'r') as f:
            # Read the sequences from the file, ignoring headers
            expected_sequences = [line.strip() for line in f if not line.startswith('>')]
        
        expected_str = "\n".join(expected_sequences)
        assert str(calculated_msa) == expected_str

    def test_empty_input(self):
        """Tests that the calculator raises an error for empty input."""
        with pytest.raises(ValueError, match="non-empty"):
            MsaStatsCalculator([])

    def test_non_string_input(self):
        """Error message identifies which index contains non-string."""
        with pytest.raises(ValueError, match="index 1.*not a string.*int"):
            MsaStatsCalculator(["ACGT", 123, "ACGT"])

    def test_mismatched_lengths(self):
        """Error message identifies which sequences have mismatched lengths."""
        with pytest.raises(ValueError, match="sequence 0 has length 4.*sequence 1 has length 3"):
            MsaStatsCalculator(["ACGT", "ACG"])

    def test_get_stat_vec(self, calculated_msa: MsaStatsCalculator):
        """
        Tests that get_stat_vec returns a list of the correct size and type.
        """
        stat_vec = calculated_msa.get_stat_vec()
        assert isinstance(stat_vec, list)
        assert len(stat_vec) == len(StatType)
        assert all(isinstance(x, float) for x in stat_vec)
        assert stat_vec[StatType.MSA_LEN.value - 1] == 37.0


class TestFastaErrorHandling:
    """Tests for FASTA file error handling."""

    def test_file_not_found(self):
        """MsaStatsError raised when FASTA file does not exist."""
        with pytest.raises(MsaStatsError, match="FASTA file not found"):
            MsaStatsCalculator.from_fasta("/nonexistent/path/to/file.fasta")

    def test_empty_fasta_file(self, tmp_path):
        """MsaStatsError raised when FASTA file contains no sequences."""
        empty_fasta = tmp_path / "empty.fasta"
        empty_fasta.write_text("")
        with pytest.raises(MsaStatsError, match="No sequences found"):
            MsaStatsCalculator.from_fasta(empty_fasta)

    def test_fasta_with_only_headers(self, tmp_path):
        """MsaStatsError raised when FASTA has headers but no sequence data."""
        headers_only = tmp_path / "headers_only.fasta"
        headers_only.write_text(">seq1\n>seq2\n")
        with pytest.raises(MsaStatsError, match="No sequences found"):
            MsaStatsCalculator.from_fasta(headers_only)

    def test_path_is_directory(self, tmp_path):
        """MsaStatsError raised when path is a directory."""
        with pytest.raises(MsaStatsError, match="directory"):
            MsaStatsCalculator.from_fasta(tmp_path)

    def test_error_includes_path(self, tmp_path):
        """Error message includes the file path for debugging."""
        bad_path = tmp_path / "missing.fasta"
        with pytest.raises(MsaStatsError) as exc_info:
            MsaStatsCalculator.from_fasta(bad_path)
        assert "missing.fasta" in str(exc_info.value)

    def test_string_path_accepted(self, tmp_path):
        """from_fasta accepts string paths, not just Path objects."""
        fasta = tmp_path / "test.fasta"
        fasta.write_text(">seq1\nACGT\n>seq2\nACGT\n")
        calc = MsaStatsCalculator.from_fasta(str(fasta))
        calc.recompute_stats()
        assert calc.msa_length == 4
