#!/usr/bin/python3
"""Tests for slpkg.changelog.Changelog._format_line() (pure formatting method)."""

import re
import sys
from io import StringIO
from pathlib import Path
from unittest.mock import MagicMock

import pytest
from slpkg.changelog import Changelog

# The day_pattern used in Changelog.display_changelog():
DAY_PATTERN = re.compile(r"\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\b", re.IGNORECASE)

GREEN = "\033[32m"
ENDC = "\033[0m"


def make_changelog(query: str = "", short: bool = False, date: str = "") -> Changelog:
    """Construct Changelog bypassing __init__ (avoids config_load/Repositories)."""
    cl: Changelog = object.__new__(Changelog)
    cl.repository = "slack"
    cl.changelog_txt = "ChangeLog.txt"
    cl.changelog_file = Path("/nonexistent/ChangeLog.txt")
    cl.option_short = short
    cl.option_for_edit = False
    cl.option_for_pager = False
    cl.option_lines = 0
    cl.option_date = date
    cl.query = query
    cl._query_re = re.compile(query, re.IGNORECASE) if query else None
    cl.red = ""
    cl.endc = ""
    cl.utils = MagicMock()
    return cl


SAMPLE_CHANGELOG = [
    "Sat Mar  7 23:16:55 UTC 2026\n",
    "a/e2fsprogs-1.47.4-x86_64-1.txz:  Upgraded.\n",
    "ap/sqlite-3.52.0-x86_64-1.txz:  Upgraded.\n",
    "  Some indented description line.\n",
    "+--------------------------+\n",
    "Fri Mar  6 00:37:27 UTC 2026\n",
    "a/kernel-generic-6.12.76-x86_64-1.txz:  Upgraded.\n",
    "testing/packages/rust-1.94.0-x86_64-1.txz:  Upgraded.\n",
    "l/openssl-3.4.1-x86_64-1.txz:  Upgraded.  (* Security fix *)\n",
]

# Ponce-style changelog (no .txz extension)
SAMPLE_PONCE_CHANGELOG = [
    "Sat Mar  7 05:28:39 UTC 2026\n",
    "system/slpkg: Updated for version 6.0.0.\n",
    "network/firefox: Updated for version 134.0.\n",
    "  Some note about the update.\n",
    "+--------------------------+\n",
    "Fri Mar  6 00:00:00 UTC 2026\n",
    "libraries/openssl: Updated for version 3.4.1. (* Security fix *)\n",
]


# ---------------------------------------------------------------------------
# Lines matching the day_pattern get green/endc wrapping
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "line",
    [
        "Mon Jan  1 12:00:00 UTC 2024\n",
        "Tue Feb 13 08:30:00 UTC 2024\n",
        "Wed Mar  6 15:45:00 UTC 2024\n",
        "Thu Apr 18 09:00:00 UTC 2024\n",
        "Fri May  3 17:20:00 UTC 2024\n",
        "Sat Jun 22 11:11:11 UTC 2024\n",
        "Sun Dec 31 23:59:59 UTC 2024\n",
    ],
)
def test_format_line_date_line_gets_colored(line: str) -> None:
    cl = make_changelog()
    result = cl._format_line(line, DAY_PATTERN, GREEN, ENDC)
    assert result == f"{GREEN}{line}{ENDC}"


@pytest.mark.parametrize(
    "line",
    [
        "mon Jan  1 12:00:00 UTC 2024\n",  # lowercase
        "MON Jan  1 12:00:00 UTC 2024\n",  # uppercase
        "SUN Dec 31 23:59:59 UTC 2024\n",  # all-caps
    ],
)
def test_format_line_case_insensitive_day_match(line: str) -> None:
    """Day pattern uses re.IGNORECASE — lowercase/uppercase days are colored."""
    cl = make_changelog()
    result = cl._format_line(line, DAY_PATTERN, GREEN, ENDC)
    assert result == f"{GREEN}{line}{ENDC}"


# ---------------------------------------------------------------------------
# Lines not matching the day_pattern are returned unchanged
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "line",
    [
        "  patches/packages/ffmpeg-7.1-x86_64-1alien.txz:  Upgraded.\n",
        "+--------------------------+\n",
        "",
        "   (* Security fix *)\n",
        "This line has no weekday.\n",
        "Monday is not abbreviated.\n",  # 'Monday' != 'Mon' as a word boundary match
    ],
)
def test_format_line_non_date_line_unchanged(line: str) -> None:
    cl = make_changelog()
    result = cl._format_line(line, DAY_PATTERN, GREEN, ENDC)
    assert result == line


def test_format_line_empty_colors_still_wraps_date() -> None:
    """Empty color strings produce no visible ANSI codes but still wrap."""
    cl = make_changelog()
    line = "Fri May  3 17:20:00 UTC 2024\n"
    result = cl._format_line(line, DAY_PATTERN, "", "")
    assert result == line  # f'{""}{"line"}{""}' == line


# ---------------------------------------------------------------------------
# display_short()
# ---------------------------------------------------------------------------


def _run_display_short(cl: Changelog) -> str:
    """Capture stdout from display_short()."""
    buf = StringIO()
    old_stdout = sys.stdout
    sys.stdout = buf
    try:
        cl.display_short()
    finally:
        sys.stdout = old_stdout
    return buf.getvalue()


# ---------------------------------------------------------------------------
# _parse_date()
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("raw, expected", [
    ("Mar  7 23:16:55 UTC 2026", "2026-03-07"),
    ("Jan  1 00:00:00 UTC 2024", "2024-01-01"),
    ("Dec 31 12:30:00 UTC 2023", "2023-12-31"),
])
def test_parse_date_valid(raw: str, expected: str) -> None:
    assert Changelog._parse_date(raw) == expected


def test_parse_date_invalid_returns_raw() -> None:
    """Unrecognised format falls back to the raw string."""
    assert Changelog._parse_date("not a date") == "not a date"


# ---------------------------------------------------------------------------
# _build_short_entries()
# ---------------------------------------------------------------------------


def test_build_short_entries_returns_formatted_lines() -> None:
    cl = make_changelog()
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    assert any("e2fsprogs-1.47.4-x86_64-1.txz: Upgraded." in e for e in entries)
    assert any("sqlite-3.52.0-x86_64-1.txz: Upgraded." in e for e in entries)


def test_build_short_entries_skips_separators_and_descriptions() -> None:
    cl = make_changelog()
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    combined = "".join(entries)
    assert "+--------------------------+" not in combined
    assert "Some indented description" not in combined


def test_build_short_entries_query_filter() -> None:
    cl = make_changelog(query="kernel")
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    pkg_entries = [e for e in entries if e.startswith("  ")]
    assert pkg_entries
    assert all("kernel" in e for e in pkg_entries)


def test_build_short_entries_ponce_format() -> None:
    cl = make_changelog()
    entries = cl._build_short_entries(SAMPLE_PONCE_CHANGELOG)
    combined = "".join(entries)
    assert "slpkg: Updated for version 6.0.0." in combined
    assert "system/slpkg" not in combined


def test_display_short_missing_file(capsys) -> None:
    """If changelog file does not exist, print a friendly message."""
    cl = make_changelog()
    cl.changelog_file = Path("/nonexistent/ChangeLog.txt")
    cl.display_short()
    out = capsys.readouterr().out
    assert "does not provide" in out


def test_display_short_basic_output() -> None:
    """Package lines are formatted as [DATE] filename: Action."""
    cl = make_changelog()
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG
    cl.changelog_file = Path("/fake/ChangeLog.txt")

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    assert "e2fsprogs-1.47.4-x86_64-1.txz: Upgraded." in output
    assert "sqlite-3.52.0-x86_64-1.txz: Upgraded." in output
    assert "kernel-generic-6.12.76-x86_64-1.txz: Upgraded." in output


def test_display_short_date_format() -> None:
    """Dates appear as [YYYY-MM-DD]."""
    cl = make_changelog()
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    assert "2026-03-07" in output
    assert "2026-03-06" in output


def test_display_short_strips_path_prefix() -> None:
    """Directory prefix (a/, l/, testing/packages/) is removed from filename."""
    cl = make_changelog()
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    # No path prefixes should appear
    assert "a/e2fsprogs" not in output
    assert "ap/sqlite" not in output
    assert "testing/packages/rust" not in output
    assert "rust-1.94.0-x86_64-1.txz" in output


def test_display_short_skips_separator_and_descriptions() -> None:
    """Separator lines and indented descriptions do not appear in output."""
    cl = make_changelog()
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    assert "+--------------------------+" not in output
    assert "Some indented description" not in output


def test_display_short_query_filter() -> None:
    """With a query, only matching package lines are shown."""
    cl = make_changelog(query="kernel")
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    assert "kernel-generic" in output
    assert "e2fsprogs" not in output
    assert "sqlite" not in output


def test_display_short_preserves_security_note() -> None:
    """Action text including '(* Security fix *)' is kept."""
    cl = make_changelog()
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    assert "Security fix" in output


def test_display_short_ponce_format() -> None:
    """SBo/Ponce-style entries (no .txz) are also parsed correctly."""
    cl = make_changelog()
    cl.utils.read_text_file.return_value = SAMPLE_PONCE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    assert "slpkg: Updated for version 6.0.0." in output
    assert "firefox: Updated for version 134.0." in output
    assert "openssl: Updated for version 3.4.1." in output
    # Category prefixes stripped
    assert "system/slpkg" not in output
    assert "network/firefox" not in output
    # Indented description skipped
    assert "Some note" not in output


# ---------------------------------------------------------------------------
# _select_date_block()
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# _validate_date()
# ---------------------------------------------------------------------------


def test_validate_date_valid_passes() -> None:
    cl = make_changelog(date="2026-03-07")
    cl._validate_date()  # should not raise


def test_validate_date_empty_passes() -> None:
    cl = make_changelog(date="")
    cl._validate_date()  # no date set — always valid


@pytest.mark.parametrize("bad_date", [
    "07-03-2026",   # wrong order
    "2026/03/07",   # wrong separator
    "20260307",     # no separator
    "2026-99-99",   # out-of-range
    "not-a-date",   # garbage
])
def test_validate_date_invalid_exits(bad_date: str, capsys) -> None:
    cl = make_changelog(date=bad_date)
    with pytest.raises(SystemExit):
        cl._validate_date()
    out = capsys.readouterr().out
    assert "invalid date" in out
    assert "YYYY-MM-DD" in out


def test_select_date_block_returns_all_when_no_date() -> None:
    cl = make_changelog()
    result = cl._select_date_block(SAMPLE_CHANGELOG)
    assert result == SAMPLE_CHANGELOG


def test_select_date_block_keeps_matching_date() -> None:
    cl = make_changelog(date="2026-03-07")
    result = cl._select_date_block(SAMPLE_CHANGELOG)
    assert any("e2fsprogs" in l for l in result)
    assert any("sqlite" in l for l in result)


def test_select_date_block_excludes_other_dates() -> None:
    cl = make_changelog(date="2026-03-07")
    result = cl._select_date_block(SAMPLE_CHANGELOG)
    assert not any("kernel-generic" in l for l in result)
    assert not any("rust" in l for l in result)


def test_select_date_block_no_match_returns_empty() -> None:
    cl = make_changelog(date="2000-01-01")
    result = cl._select_date_block(SAMPLE_CHANGELOG)
    assert result == []


def test_select_date_block_excludes_separator_lines() -> None:
    cl = make_changelog(date="2026-03-07")
    result = cl._select_date_block(SAMPLE_CHANGELOG)
    assert not any(l.startswith("+") for l in result)


# ---------------------------------------------------------------------------
# display_short() with --date
# ---------------------------------------------------------------------------


def test_display_short_date_filter_shows_only_matching_date() -> None:
    cl = make_changelog(date="2026-03-07")
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    assert "e2fsprogs" in output
    assert "sqlite" in output
    assert "kernel-generic" not in output


def test_display_short_date_no_match_shows_hint(capsys) -> None:
    cl = make_changelog(date="2000-01-01")
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        cl.display_short()

    out = capsys.readouterr().out
    assert "unsupported format" in out or "No structured entries" in out


def test_display_short_unsupported_format_shows_hint(capsys) -> None:
    """If no entries match the regex, print a helpful message instead of silent empty output."""
    cl = make_changelog()
    cl.utils.read_text_file.return_value = [
        "2026-03-07: ffmpeg upgraded\n",   # no category/ prefix
        "ffmpeg-6.1: Upgraded.\n",          # no slash
        "  indented line\n",
    ]

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        cl.display_short()

    out = capsys.readouterr().out
    assert "unsupported format" in out
    assert "--short" in out


def test_display_short_option_lines_limits_entries() -> None:
    """-n N limits output to the first N package entries (date headers are not counted)."""
    cl = make_changelog()
    cl.option_lines = 2
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    import unittest.mock as mock
    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    pkg_lines = [l for l in output.splitlines() if l.startswith("  ")]
    assert len(pkg_lines) == 2
    assert "e2fsprogs" in pkg_lines[0]
    assert "sqlite" in pkg_lines[1]


def test_display_short_pager_receives_output() -> None:
    """-g/--pager pipes all entries through the configured pager."""
    import unittest.mock as mock

    cl = make_changelog()
    cl.option_for_pager = True
    cl.pager = "cat"
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    written: list[str] = []

    mock_stdin = MagicMock()
    mock_stdin.write.side_effect = written.append

    mock_proc = MagicMock()
    mock_proc.__enter__ = lambda s: s
    mock_proc.__exit__ = MagicMock(return_value=False)
    mock_proc.stdin = mock_stdin

    with mock.patch.object(Path, "is_file", return_value=True), \
         mock.patch("subprocess.Popen", return_value=mock_proc):
        cl.display_short()

    combined = "".join(written)
    assert "e2fsprogs" in combined
    assert "kernel-generic" in combined
    # Separator and descriptions must not appear
    assert "+--------------------------+" not in combined
    assert "Some indented" not in combined


# ---------------------------------------------------------------------------
# _compile_query() — validation and regex compilation
# ---------------------------------------------------------------------------


def test_compile_query_empty_sets_none() -> None:
    cl = make_changelog(query="")
    cl._compile_query()
    assert cl._query_re is None


def test_compile_query_plain_text_compiles() -> None:
    cl = make_changelog()
    cl.query = "kernel"
    cl._compile_query()
    assert cl._query_re is not None
    assert cl._query_re.search("kernel-generic-6.12.76-x86_64-1.txz")


def test_compile_query_is_case_insensitive() -> None:
    cl = make_changelog()
    cl.query = "KERNEL"
    cl._compile_query()
    assert cl._query_re is not None
    assert cl._query_re.search("kernel-generic-6.12.76-x86_64-1.txz")


def test_compile_query_invalid_regex_exits(capsys) -> None:
    cl = make_changelog()
    cl.query = "(unclosed"
    with pytest.raises(SystemExit) as exc:
        cl._compile_query()
    assert exc.value.code == 1
    out = capsys.readouterr().out
    assert "invalid regex" in out
    assert "(unclosed" in out


def test_compile_query_invalid_regex_shows_tip(capsys) -> None:
    cl = make_changelog()
    cl.query = "[bad"
    with pytest.raises(SystemExit):
        cl._compile_query()
    out = capsys.readouterr().out
    assert "Tip" in out or "escape" in out.lower()


# ---------------------------------------------------------------------------
# Regex queries in _build_short_entries()
# ---------------------------------------------------------------------------


def test_build_short_entries_regex_alternation() -> None:
    """'kernel|sqlite' matches both packages."""
    cl = make_changelog(query="kernel|sqlite")
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    names = "".join(entries)
    assert "kernel-generic" in names
    assert "sqlite" in names
    assert "e2fsprogs" not in names


def test_build_short_entries_regex_dot_star() -> None:
    """'openssl.*security' matches the openssl security-fix line."""
    cl = make_changelog(query=r"openssl.*security")
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    pkg_entries = [e for e in entries if e.startswith("  ")]
    assert len(pkg_entries) == 1
    assert "openssl" in pkg_entries[0]


def test_build_short_entries_regex_anchored_category() -> None:
    """'^a/' matches packages in a/ but not ap/, l/, testing/."""
    cl = make_changelog(query=r"^a/")
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    # e2fsprogs and kernel-generic are both in a/
    names = "".join(entries)
    assert "e2fsprogs" in names
    assert "kernel-generic" in names
    # sqlite is in ap/ — must NOT match ^a/
    assert "sqlite" not in names
    # openssl is in l/ — must NOT match
    assert "openssl" not in names


def test_build_short_entries_no_query_returns_all() -> None:
    cl = make_changelog(query="")
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    pkg_entries = [e for e in entries if e.startswith("  ")]
    assert len(pkg_entries) == 5  # all five package lines
    assert entries.count("\n") == 1  # one blank line separator between the two date groups


def test_build_short_entries_regex_case_insensitive() -> None:
    """Regex match is always case-insensitive."""
    cl = make_changelog(query="KERNEL")
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    assert any("kernel-generic" in e for e in entries)


def test_build_short_entries_regex_special_chars_literal() -> None:
    """Escaped dot in query 'openssl-3\\.4' matches the exact version string."""
    cl = make_changelog(query=r"openssl-3\.4")
    entries = cl._build_short_entries(SAMPLE_CHANGELOG)
    pkg_entries = [e for e in entries if e.startswith("  ")]
    assert len(pkg_entries) == 1
    assert "openssl" in pkg_entries[0]


def test_build_short_entries_ponce_regex_alternation() -> None:
    """'firefox|slpkg' matches two Ponce entries."""
    cl = make_changelog(query="firefox|slpkg")
    entries = cl._build_short_entries(SAMPLE_PONCE_CHANGELOG)
    names = "".join(entries)
    assert "firefox" in names
    assert "slpkg" in names
    assert "openssl" not in names


# ---------------------------------------------------------------------------
# Regex queries in display_short()
# ---------------------------------------------------------------------------


def test_display_short_regex_query() -> None:
    """display_short() honours a regex query."""
    import unittest.mock as mock

    cl = make_changelog(query="kernel|rust")
    cl.utils.read_text_file.return_value = SAMPLE_CHANGELOG

    with mock.patch.object(Path, "is_file", return_value=True):
        output = _run_display_short(cl)

    assert "kernel-generic" in output
    assert "rust" in output
    assert "e2fsprogs" not in output
    assert "sqlite" not in output
