#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2024 by the Linux Foundation
#
"""Unit tests for b4 bugs helpers.

Tests the pure-logic functions in _import.py and _tui.py that don't
need Textual, git-bug, or network access, plus one Textual-driven
test of bug-list scroll preservation (with a stubbed repo, so still
no git-bug or network).
"""

from datetime import datetime, timedelta, timezone
from typing import Set, cast
from unittest import mock

import pytest

pytest.importorskip('textual')

from b4.bugs._import import (
    format_comment,
    is_comment_removed,
    make_tombstone,
    parse_comment_header,
    parse_comment_msgid,
)
from b4.bugs._tui import (
    BugListApp,
    _bug_last_activity,
    _bug_lifecycle,
    _bug_tier,
    _relative_time,
    build_label_text,
    label_color,
)
from ezgb import Bug, BugSummary, Comment, GitBugRepo, Identity, Status

# ---------------------------------------------------------------------------
# Helpers -- factory functions for real Bug and BugSummary objects
# ---------------------------------------------------------------------------

_EPOCH = datetime(2026, 1, 1, tzinfo=timezone.utc)
_IDENTITY = Identity(id='test', name='Test', email='test@test.com', login='')


def make_bug(
    *,
    status: Status = Status.OPEN,
    labels: Set[str] | None = None,
    comments: list[Comment] | None = None,
    created_at: datetime | None = None,
    title: str = '',
) -> Bug:
    return Bug(
        id='deadbeef' * 8,
        title=title,
        status=status,
        creator=_IDENTITY,
        created_at=created_at or _EPOCH,
        labels=labels or set(),
        comments=comments or [],
    )


def make_comment(created_at: datetime) -> Comment:
    return Comment(
        id='c0ffee' * 11,
        author=_IDENTITY,
        text='',
        created_at=created_at,
        count=0,
        attachment_ids=[],
    )


def make_summary(
    *,
    status: Status = Status.OPEN,
    labels: frozenset[str] | None = None,
    edited_at: datetime | None = None,
    created_at: datetime | None = None,
    title: str = '',
    comment_count: int = 0,
    author_name: str = '',
    bug_id: str | None = None,
) -> BugSummary:
    return BugSummary(
        id=bug_id or 'deadbeef' * 8,
        title=title,
        status=status,
        creator_id='test',
        created_at=created_at or _EPOCH,
        labels=labels or frozenset(),
        comment_count=comment_count,
        author_name=author_name,
        edited_at=edited_at or _EPOCH,
    )


# ===========================================================================
# _import.py tests
# ===========================================================================


class TestParseCommentHeader:
    def test_extracts_from(self) -> None:
        text = 'From: Alice <alice@example.com>\nDate: Mon, 1 Jan 2026\n\nBody'
        assert parse_comment_header(text, 'From') == 'Alice <alice@example.com>'

    def test_extracts_message_id(self) -> None:
        text = 'Message-ID: <abc@example.com>\n\nBody'
        assert parse_comment_header(text, 'Message-ID') == '<abc@example.com>'

    def test_case_insensitive(self) -> None:
        text = 'message-id: <abc@example.com>\n\nBody'
        assert parse_comment_header(text, 'Message-ID') == '<abc@example.com>'

    def test_returns_none_for_missing(self) -> None:
        text = 'From: Alice\n\nBody'
        assert parse_comment_header(text, 'Date') is None

    def test_does_not_match_body(self) -> None:
        text = 'From: Alice\n\nMessage-ID: <fake@body>'
        assert parse_comment_header(text, 'Message-ID') is None

    def test_empty_text(self) -> None:
        assert parse_comment_header('', 'From') is None

    def test_no_body_separator(self) -> None:
        text = 'From: Alice'
        assert parse_comment_header(text, 'From') == 'Alice'


class TestParseCommentMsgid:
    def test_strips_angle_brackets(self) -> None:
        text = 'Message-ID: <abc@example.com>\n\nBody'
        assert parse_comment_msgid(text) == 'abc@example.com'

    def test_returns_none_when_missing(self) -> None:
        text = 'From: Alice\n\nBody'
        assert parse_comment_msgid(text) is None


class TestIsCommentRemoved:
    def test_detects_tombstone(self) -> None:
        text = 'Message-ID: <abc@example.com>\nX-B4-Bug-Comment: removed by Alice <alice@test.com>'
        assert is_comment_removed(text) is True

    def test_normal_comment(self) -> None:
        text = 'From: Alice\nMessage-ID: <abc@example.com>\n\nNormal body'
        assert is_comment_removed(text) is False

    def test_empty_text(self) -> None:
        assert is_comment_removed('') is False


class TestMakeTombstone:
    def test_preserves_message_id(self) -> None:
        text = 'From: Alice\nMessage-ID: <abc@example.com>\nIn-Reply-To: <parent@example.com>\n\nBody text'
        tomb = make_tombstone(text, 'Admin <admin@test.com>')
        assert 'Message-ID: <abc@example.com>' in tomb
        assert 'In-Reply-To: <parent@example.com>' in tomb
        assert 'X-B4-Bug-Comment: removed by Admin <admin@test.com>' in tomb

    def test_strips_from_and_body(self) -> None:
        text = 'From: Alice\nDate: Mon, 1 Jan\nMessage-ID: <abc@example.com>\n\nSensitive body'
        tomb = make_tombstone(text, 'Admin <admin@test.com>')
        assert 'Alice' not in tomb
        assert 'Sensitive body' not in tomb
        assert 'Date:' not in tomb

    def test_roundtrip_with_is_removed(self) -> None:
        text = 'Message-ID: <abc@example.com>\n\nBody'
        tomb = make_tombstone(text, 'Admin <admin@test.com>')
        assert is_comment_removed(tomb)

    def test_msgid_preserved_for_dedup(self) -> None:
        text = 'Message-ID: <abc@example.com>\n\nBody'
        tomb = make_tombstone(text, 'Admin <admin@test.com>')
        assert parse_comment_msgid(tomb) == 'abc@example.com'

    def test_no_message_id(self) -> None:
        text = 'From: Alice\n\nInternal comment'
        tomb = make_tombstone(text, 'Admin <admin@test.com>')
        assert 'X-B4-Bug-Comment: removed by Admin' in tomb
        assert 'Message-ID' not in tomb


class TestFormatComment:
    def test_includes_headers(self) -> None:
        msg = mock.MagicMock()
        msg.get.side_effect = lambda h, *a: {
            'From': 'Alice <alice@test.com>',
            'Date': 'Mon, 1 Jan 2026 00:00:00 +0000',
            'Message-ID': '<abc@test.com>',
            'In-Reply-To': '<parent@test.com>',
        }.get(h)
        with mock.patch('b4.LoreMessage.clean_header', side_effect=lambda x: x):
            with mock.patch(
                'b4.LoreMessage.get_payload', return_value=('Body text', 'utf-8')
            ):
                result = format_comment(msg)
        assert 'From: Alice <alice@test.com>' in result
        assert 'Message-ID: <abc@test.com>' in result
        assert 'Body text' in result

    def test_scope_header(self) -> None:
        msg = mock.MagicMock()
        msg.get.side_effect = lambda h, *a: {
            'From': 'Alice',
            'Message-ID': '<abc@test.com>',
        }.get(h)
        with mock.patch('b4.LoreMessage.clean_header', side_effect=lambda x: x):
            with mock.patch(
                'b4.LoreMessage.get_payload', return_value=('Body', 'utf-8')
            ):
                result = format_comment(msg, scope='no-parent')
        assert 'X-B4-Bug-Scope: no-parent' in result


# ===========================================================================
# _tui.py tests
# ===========================================================================


class TestLabelColor:
    def test_deterministic(self) -> None:
        c1 = label_color('review')
        c2 = label_color('review')
        assert c1 == c2

    def test_different_labels_differ(self) -> None:
        c1 = label_color('review')
        c2 = label_color('priority/high')
        # Not guaranteed but extremely likely with different inputs
        assert isinstance(c1, str) and c1.startswith('#')
        assert isinstance(c2, str) and c2.startswith('#')

    def test_returns_hex_color(self) -> None:
        c = label_color('test')
        assert c.startswith('#')
        assert len(c) == 7  # #rrggbb


class TestBuildLabelText:
    def test_empty_labels_returns_empty_text(self) -> None:
        result = build_label_text([])
        assert str(result) == ''

    def test_lifecycle_labels_are_excluded(self) -> None:
        result = build_label_text(['lifecycle:confirmed', 'lifecycle:fixed'])
        assert str(result) == ''

    def test_normal_label_appears(self) -> None:
        result = build_label_text(['area/network'])
        assert 'area/network' in str(result)

    def test_lifecycle_mixed_with_normal(self) -> None:
        result = build_label_text(['lifecycle:confirmed', 'priority/high'])
        text = str(result)
        assert 'priority/high' in text
        assert 'lifecycle:confirmed' not in text

    def test_labels_are_sorted(self) -> None:
        result = build_label_text(['zzz', 'aaa', 'mmm'])
        text = str(result)
        assert text.index('aaa') < text.index('mmm') < text.index('zzz')

    def test_only_lifecycle_labels_excluded_not_others(self) -> None:
        # A label that merely *contains* 'lifecycle' but doesn't start with it
        # should still be shown.
        result = build_label_text(['not-lifecycle:foo'])
        assert 'not-lifecycle:foo' in str(result)


class TestBugTier:
    def test_open_new_is_tier_0(self) -> None:
        bug = make_bug()
        assert _bug_tier(bug) == 0

    def test_confirmed_is_tier_0(self) -> None:
        bug = make_bug(labels={'lifecycle:confirmed'})
        assert _bug_tier(bug) == 0

    def test_needinfo_is_tier_1(self) -> None:
        bug = make_bug(labels={'lifecycle:needinfo'})
        assert _bug_tier(bug) == 1

    def test_fixed_is_tier_2(self) -> None:
        bug = make_bug(labels={'lifecycle:fixed'})
        assert _bug_tier(bug) == 2

    def test_closed_is_tier_2(self) -> None:
        from ezgb import Status

        bug = make_bug(status=Status.CLOSED)
        assert _bug_tier(bug) == 2

    def test_summary_works(self) -> None:
        s = make_summary(labels=frozenset({'lifecycle:needinfo'}))
        assert _bug_tier(s) == 1


class TestBugLifecycle:
    def test_new_default(self) -> None:
        bug = make_bug()
        assert _bug_lifecycle(bug) == '\u2605'  # ★

    def test_confirmed(self) -> None:
        bug = make_bug(labels={'lifecycle:confirmed'})
        assert _bug_lifecycle(bug) == '\u00a4'  # ¤

    def test_fixed(self) -> None:
        bug = make_bug(labels={'lifecycle:fixed'})
        assert _bug_lifecycle(bug) == '\u2713'  # ✓

    def test_duplicate(self) -> None:
        bug = make_bug(labels={'lifecycle:duplicate'})
        assert _bug_lifecycle(bug) == '\u2261'  # ≡

    def test_closed_no_lifecycle(self) -> None:
        from ezgb import Status

        bug = make_bug(status=Status.CLOSED)
        assert _bug_lifecycle(bug) == '\u00d7'  # ×


class TestBugLastActivity:
    def test_uses_last_comment(self) -> None:
        comment_time = datetime(2026, 3, 15, tzinfo=timezone.utc)
        created_time = datetime(2026, 1, 1, tzinfo=timezone.utc)
        bug = make_bug(
            created_at=created_time,
            comments=[make_comment(comment_time)],
        )
        assert _bug_last_activity(bug) == comment_time

    def test_fallback_to_created_at(self) -> None:
        created_time = datetime(2026, 1, 1, tzinfo=timezone.utc)
        bug = make_bug(created_at=created_time, comments=[])
        assert _bug_last_activity(bug) == created_time

    def test_summary_uses_edited_at(self) -> None:
        from ezgb import BugSummary, Status

        edited_time = datetime(2026, 4, 1, tzinfo=timezone.utc)
        s = BugSummary(
            id='a' * 64,
            title='Test',
            status=Status.OPEN,
            creator_id='b' * 64,
            created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            labels=frozenset(),
            comment_count=1,
            edited_at=edited_time,
        )
        assert _bug_last_activity(s) == edited_time


class TestRelativeTime:
    def test_just_now(self) -> None:
        now = datetime.now(tz=timezone.utc)
        assert _relative_time(now) == 'just now'

    def test_minutes(self) -> None:
        from datetime import timedelta

        t = datetime.now(tz=timezone.utc) - timedelta(minutes=5)
        assert '5m ago' == _relative_time(t)

    def test_hours(self) -> None:
        from datetime import timedelta

        t = datetime.now(tz=timezone.utc) - timedelta(hours=3)
        assert '3h ago' == _relative_time(t)

    def test_days(self) -> None:
        from datetime import timedelta

        t = datetime.now(tz=timezone.utc) - timedelta(days=7)
        assert '7d ago' == _relative_time(t)


class TestMatchesLimit:
    def test_bare_text_matches_title(self) -> None:
        bug = make_bug(title='Crash on startup')
        assert BugListApp._matches_limit(bug, 'crash') is True

    def test_bare_text_case_insensitive(self) -> None:
        bug = make_bug(title='Crash on startup')
        assert BugListApp._matches_limit(bug, 'CRASH') is True

    def test_bare_text_no_match(self) -> None:
        bug = make_bug(title='Crash on startup')
        assert BugListApp._matches_limit(bug, 'login') is False

    def test_status_filter_open(self) -> None:
        from ezgb import Status

        bug = make_bug(status=Status.OPEN)
        assert BugListApp._matches_limit(bug, 's:open') is True
        assert BugListApp._matches_limit(bug, 's:closed') is False

    def test_status_filter_closed(self) -> None:
        from ezgb import Status

        bug = make_bug(status=Status.CLOSED)
        assert BugListApp._matches_limit(bug, 's:closed') is True
        assert BugListApp._matches_limit(bug, 's:open') is False

    def test_label_filter(self) -> None:
        bug = make_bug(labels={'area/network', 'priority/high'})
        assert BugListApp._matches_limit(bug, 'l:network') is True
        assert BugListApp._matches_limit(bug, 'l:web') is False

    def test_combined_tokens(self) -> None:
        bug = make_bug(
            title='Network crash',
            labels={'area/network'},
        )
        assert BugListApp._matches_limit(bug, 'crash l:network') is True
        assert BugListApp._matches_limit(bug, 'crash l:web') is False

    def test_empty_pattern(self) -> None:
        bug = make_bug(title='Anything')
        assert BugListApp._matches_limit(bug, '') is True

    def test_summary_works(self) -> None:
        from ezgb import Status

        s = make_summary(
            title='Network bug',
            status=Status.OPEN,
            labels=frozenset({'area/network'}),
        )
        assert BugListApp._matches_limit(s, 'network l:network s:open') is True


class TestBuildActions:
    """Test context-sensitive action list building."""

    @staticmethod
    def _build(status: str = 'open', lifecycle: str = '') -> list[tuple[str, str]]:
        labels: Set[str] = set()
        if lifecycle:
            labels.add(f'lifecycle:{lifecycle}')
        bug = make_bug(
            status=Status.CLOSED if status == 'closed' else Status.OPEN,
            labels=labels,
        )
        return BugListApp._build_actions(bug)

    def test_new_bug_actions(self) -> None:
        actions = self._build()
        keys = [k for k, _label in actions]
        assert 'confirmed' in keys
        assert 'needinfo' in keys
        assert 'fixed' in keys
        assert 'worksforme' in keys
        assert 'wontfix' in keys
        assert 'duplicate' in keys
        assert 'delete' in keys

    def test_confirmed_bug_has_fixed(self) -> None:
        actions = self._build(lifecycle='confirmed')
        keys = [k for k, _label in actions]
        assert 'fixed' in keys
        assert 'confirmed' not in keys  # already confirmed

    def test_closed_bug_has_reopen(self) -> None:
        actions = self._build(status='closed')
        keys = [k for k, _label in actions]
        assert 'reopen' in keys
        assert 'delete' in keys
        assert len(keys) == 2  # only reopen + delete

    def test_needinfo_has_confirm(self) -> None:
        actions = self._build(lifecycle='needinfo')
        keys = [k for k, _label in actions]
        assert 'confirmed' in keys
        assert 'needinfo' not in keys

    def test_close_reasons_always_available(self) -> None:
        """All close reasons are available from any open lifecycle state."""
        for lifecycle in ('', 'new', 'confirmed', 'needinfo'):
            actions = self._build(lifecycle=lifecycle)
            keys = [k for k, _label in actions]
            assert 'fixed' in keys, f'fixed missing for {lifecycle!r}'
            assert 'worksforme' in keys, f'worksforme missing for {lifecycle!r}'
            assert 'wontfix' in keys, f'wontfix missing for {lifecycle!r}'
            assert 'duplicate' in keys, f'duplicate missing for {lifecycle!r}'


class TestParseMsgidForImport:
    """Verify that import_thread accepts URLs and bare message-ids."""

    def test_bare_msgid(self) -> None:
        import b4

        result = b4.parse_msgid('abc123@example.com')
        assert result == 'abc123@example.com'

    def test_angle_bracketed(self) -> None:
        import b4

        result = b4.parse_msgid('<abc123@example.com>')
        assert result == 'abc123@example.com'

    def test_lore_url(self) -> None:
        import b4

        result = b4.parse_msgid('https://lore.kernel.org/all/abc123@example.com/')
        assert result == 'abc123@example.com'

    def test_patch_msgid_link(self) -> None:
        import b4

        result = b4.parse_msgid('https://patch.msgid.link/abc123@example.com')
        assert result == 'abc123@example.com'

    def test_garbage_has_no_at(self) -> None:
        import b4

        result = b4.parse_msgid('not-a-msgid')
        assert '@' not in result


class TestQuitBindings:
    """BugListApp quits on capital 'Q'; bare 'q' only shows a hint."""

    def test_quit_takes_capital_q(self) -> None:
        from textual.binding import Binding

        bmap = {b.key: b for b in BugListApp.BINDINGS if isinstance(b, Binding)}
        assert bmap['Q'].action == 'quit'
        assert bmap['q'].action == 'quit_hint'
        assert bmap['q'].show is False


class TestRefreshScrollPreservation:
    """The bug list is rebuilt wholesale on refresh; the viewport must stay put."""

    @pytest.mark.asyncio
    async def test_refresh_preserves_scroll_position(self) -> None:
        from textual.widgets import ListView

        many = [
            make_summary(
                bug_id=f'{i:064x}',
                title=f'bug {i:02d}',
                edited_at=_EPOCH + timedelta(hours=40 - i),
            )
            for i in range(40)
        ]
        repo = mock.Mock()
        repo._repo = '/nonexistent/bugs-repo'
        repo.list_bug_summaries.return_value = many

        app = BugListApp(cast(GitBugRepo, repo))
        async with app.run_test(size=(120, 30)) as pilot:
            await app.workers.wait_for_complete()
            await pilot.pause()
            await pilot.pause()
            lv = app.query_one('#bug-list', ListView)
            # Move deep enough into the list that the view scrolls down,
            # then back up a bit so the cursor sits mid-viewport rather
            # than at the bottom edge (which a minimal scroll-into-view
            # after the rebuild would happen to reproduce).
            lv.index = 30
            await pilot.pause()
            await pilot.pause()
            lv.index = 26
            await pilot.pause()
            await pilot.pause()
            scroll_before = round(lv.scroll_y)
            assert scroll_before > 0
            selected = app._get_selected_bug()
            assert selected is not None
            bug_id = selected.id

            # Reload the same way _check_cache_changed does
            app._save_focus()
            app.run_worker(app._load_bugs, name='load_bugs', thread=True)
            await app.workers.wait_for_complete()
            await pilot.pause()
            await pilot.pause()
            await pilot.pause()

            new_lv = app.query_one('#bug-list', ListView)
            assert new_lv is not lv
            assert new_lv.index == 26
            assert round(new_lv.scroll_y) == scroll_before
            assert round(new_lv.vertical_scrollbar.position) == scroll_before
            selected = app._get_selected_bug()
            assert selected is not None
            assert selected.id == bug_id
