#!/usr/bin/env python3
"""Test suite for the Excel parser

Covers header detection, subcategory recognition, sheet selection,
tab/row field injection, and empty-row skipping.
"""

import pytest
from dlist import Dlist

try:
    from openpyxl import Workbook
    HAS_OPENPYXL = True
except ImportError:
    HAS_OPENPYXL = False

pytestmark = pytest.mark.skipif(not HAS_OPENPYXL, reason="openpyxl not installed")


# ─── Fixtures ────────────────────────────────────────────────

@pytest.fixture
def simple_xlsx(tmp_path):
    """Single sheet, header on row 1, no subcategories."""
    wb = Workbook()
    ws = wb.active
    ws.title = 'Data'
    ws['A1'] = 'Name'
    ws['B1'] = 'Value'
    ws['A2'] = 'alpha'
    ws['B2'] = '10'
    ws['A3'] = 'beta'
    ws['B3'] = '20'
    path = tmp_path / 'simple.xlsx'
    wb.save(path)
    return str(path)


@pytest.fixture
def multi_sheet_xlsx(tmp_path):
    """Two sheets, header on row 1."""
    wb = Workbook()
    ws1 = wb.active
    ws1.title = 'Evidence'
    ws1['A1'] = 'ID'
    ws1['B1'] = 'Name'
    ws1['A2'] = 'E001'
    ws1['B2'] = 'photo1.jpg'
    ws1['A3'] = 'E002'
    ws1['B3'] = 'video1.mp4'

    ws2 = wb.create_sheet('Analysis')
    ws2['A1'] = 'ID'
    ws2['B1'] = 'Result'
    ws2['A2'] = 'E001'
    ws2['B2'] = 'positive'
    ws2['A3'] = 'E002'
    ws2['B3'] = 'negative'

    path = tmp_path / 'multi.xlsx'
    wb.save(path)
    return str(path)


@pytest.fixture
def multiline_header_xlsx(tmp_path):
    """Header on row 3, title rows above."""
    wb = Workbook()
    ws = wb.active
    ws['A1'] = 'CONFIDENTIAL'
    ws['A2'] = 'Report v2.0'
    ws['A3'] = 'Code'
    ws['B3'] = 'Item'
    ws['C3'] = 'Qty'
    ws['D3'] = 'Notes'
    ws['A4'] = 'X01'
    ws['B4'] = 'Widget'
    ws['C4'] = '5'
    ws['D4'] = 'urgent'
    ws['A5'] = 'X02'
    ws['B5'] = 'Gadget'
    ws['C5'] = '12'
    path = tmp_path / 'multiline.xlsx'
    wb.save(path)
    return str(path)


@pytest.fixture
def categories_xlsx(tmp_path):
    """Sheet with subcategory rows."""
    wb = Workbook()
    ws = wb.active
    ws.title = 'Items'
    ws['A1'] = 'ID'
    ws['B1'] = 'Description'
    ws['C1'] = 'Status'
    # Data before any category
    ws['A2'] = 'E001'
    ws['B2'] = 'First item'
    ws['C2'] = 'ok'
    ws['A3'] = 'E002'
    ws['B3'] = 'Second item'
    ws['C3'] = 'ok'
    # Empty row 4
    # Subcategory row 5: col A empty, col B has value
    ws['B5'] = 'Category B'
    # Data under Category B
    ws['A6'] = 'E003'
    ws['B6'] = 'Third item'
    ws['C6'] = 'pending'
    ws['A7'] = 'E004'
    ws['B7'] = 'Fourth item'
    ws['C7'] = 'done'
    # Empty row 8
    # Another subcategory
    ws['B9'] = 'Category C'
    ws['A10'] = 'E005'
    ws['B10'] = 'Fifth item'
    ws['C10'] = 'ok'
    path = tmp_path / 'categories.xlsx'
    wb.save(path)
    return str(path)


@pytest.fixture
def empty_rows_xlsx(tmp_path):
    """Sheet with scattered empty rows."""
    wb = Workbook()
    ws = wb.active
    ws['A1'] = 'ID'
    ws['B1'] = 'Val'
    ws['A2'] = 'X1'
    ws['B2'] = '10'
    # Row 3 empty
    ws['A4'] = 'X2'
    ws['B4'] = '20'
    # Row 5 empty
    # Row 6 empty
    ws['A7'] = 'X3'
    ws['B7'] = '30'
    path = tmp_path / 'empty_rows.xlsx'
    wb.save(path)
    return str(path)


# ─── Basic parsing ───────────────────────────────────────────

class TestBasicParsing:
    def test_simple_parse(self, simple_xlsx):
        d = Dlist.read(simple_xlsx, format='excel')
        assert len(d) == 2
        assert d[0]['Name'] == 'alpha'
        assert d[1]['Value'] == '20'

    def test_row_field_present(self, simple_xlsx):
        d = Dlist.read(simple_xlsx, format='excel')
        assert d[0]['row'] == '2'
        assert d[1]['row'] == '3'

    def test_no_tab_field_single_sheet(self, simple_xlsx):
        d = Dlist.read(simple_xlsx, format='excel')
        assert 'tab' not in d[0]

    def test_all_values_strings(self, simple_xlsx):
        d = Dlist.read(simple_xlsx, format='excel')
        for item in d:
            for k, v in item.items():
                assert isinstance(v, str)


# ─── Multi-sheet ─────────────────────────────────────────────

class TestMultiSheet:
    def test_tab_field_present(self, multi_sheet_xlsx):
        d = Dlist.read(multi_sheet_xlsx, format='excel')
        assert all('tab' in item for item in d)

    def test_tab_values(self, multi_sheet_xlsx):
        d = Dlist.read(multi_sheet_xlsx, format='excel')
        tabs = set(item['tab'] for item in d)
        assert 'Evidence' in tabs
        assert 'Analysis' in tabs

    def test_total_records(self, multi_sheet_xlsx):
        d = Dlist.read(multi_sheet_xlsx, format='excel')
        assert len(d) == 4  # 2 per sheet

    def test_sheet_selection_single(self, multi_sheet_xlsx):
        d = Dlist.read(multi_sheet_xlsx, format='excel', sheets=[2])
        assert len(d) == 2
        assert 'tab' not in d[0]  # single parsed sheet
        assert d[0]['Result'] == 'positive'

    def test_sheet_selection_multiple(self, multi_sheet_xlsx):
        d = Dlist.read(multi_sheet_xlsx, format='excel', sheets=[1, 2])
        assert len(d) == 4
        assert all('tab' in item for item in d)

    def test_invalid_sheet_number(self, multi_sheet_xlsx):
        with pytest.raises(ValueError, match='out of range'):
            Dlist.read(multi_sheet_xlsx, format='excel', sheets=[99])


# ─── Header detection ───────────────────────────────────────

class TestHeaderDetection:
    def test_explicit_header_row(self, multiline_header_xlsx):
        d = Dlist.read(multiline_header_xlsx, format='excel', header_row=3)
        assert len(d) == 2
        assert d[0]['Code'] == 'X01'
        assert d[0]['Item'] == 'Widget'

    def test_auto_detect_header(self, multiline_header_xlsx):
        d = Dlist.read(multiline_header_xlsx, format='excel')
        # Row 3 has 4 string cells out of 4 columns → should be detected
        assert d[0]['Code'] == 'X01'

    def test_data_start_override(self, multiline_header_xlsx):
        d = Dlist.read(multiline_header_xlsx, format='excel',
                       header_row=3, data_start=5)
        assert len(d) == 1
        assert d[0]['Code'] == 'X02'


# ─── Subcategories ──────────────────────────────────────────

class TestSubcategories:
    def test_items_before_category_lack_field(self, categories_xlsx):
        d = Dlist.read(categories_xlsx, format='excel')
        early = [item for item in d if item['ID'] in ('E001', 'E002')]
        assert all('category' not in item for item in early)

    def test_category_assigned(self, categories_xlsx):
        d = Dlist.read(categories_xlsx, format='excel')
        e003 = [item for item in d if item['ID'] == 'E003'][0]
        assert e003['category'] == 'Category B'

    def test_category_persists(self, categories_xlsx):
        d = Dlist.read(categories_xlsx, format='excel')
        e004 = [item for item in d if item['ID'] == 'E004'][0]
        assert e004['category'] == 'Category B'

    def test_category_changes(self, categories_xlsx):
        d = Dlist.read(categories_xlsx, format='excel')
        e005 = [item for item in d if item['ID'] == 'E005'][0]
        assert e005['category'] == 'Category C'

    def test_subcategory_row_not_in_data(self, categories_xlsx):
        d = Dlist.read(categories_xlsx, format='excel')
        descs = [item.get('Description', '') for item in d]
        assert 'Category B' not in descs
        assert 'Category C' not in descs

    def test_total_data_records(self, categories_xlsx):
        d = Dlist.read(categories_xlsx, format='excel')
        assert len(d) == 5


# ─── Empty rows ─────────────────────────────────────────────

class TestEmptyRows:
    def test_empty_rows_skipped(self, empty_rows_xlsx):
        d = Dlist.read(empty_rows_xlsx, format='excel')
        assert len(d) == 3

    def test_row_numbers_preserved(self, empty_rows_xlsx):
        d = Dlist.read(empty_rows_xlsx, format='excel')
        rows = [item['row'] for item in d]
        assert rows == ['2', '4', '7']


# ─── Per-sheet config (dict) ─────────────────────────────────

@pytest.fixture
def mixed_headers_xlsx(tmp_path):
    """Sheet 1: header on row 3.  Sheet 2: header on row 1.  Sheet 3: header on row 3."""
    wb = Workbook()
    ws1 = wb.active
    ws1.title = 'Report'
    ws1['A1'] = 'Title'
    ws1['A2'] = 'Subtitle'
    ws1['A3'] = 'Code'
    ws1['B3'] = 'Item'
    ws1['A4'] = 'R01'
    ws1['B4'] = 'Widget'
    ws1['A5'] = 'R02'
    ws1['B5'] = 'Gadget'

    ws2 = wb.create_sheet('Timeline')
    ws2['A1'] = 'ID'
    ws2['B1'] = 'Date'
    ws2['A2'] = 'T01'
    ws2['B2'] = '2024-01-01'
    ws2['A3'] = 'T02'
    ws2['B3'] = '2024-02-01'

    ws3 = wb.create_sheet('Summary')
    ws3['A1'] = 'Overview'
    ws3['A2'] = 'v1.0'
    ws3['A3'] = 'Metric'
    ws3['B3'] = 'Value'
    ws3['A4'] = 'total'
    ws3['B4'] = '42'

    path = tmp_path / 'mixed.xlsx'
    wb.save(path)
    return str(path)


class TestPerSheetConfig:
    def test_dict_header_row(self, mixed_headers_xlsx):
        """Different header_row per sheet via dict."""
        d = Dlist.read(mixed_headers_xlsx, format='excel',
                       header_row={1: 3, 2: 1, 3: 3})
        codes = [item.get('Code', item.get('ID', item.get('Metric', '')))
                 for item in d]
        assert 'R01' in codes
        assert 'T01' in codes
        assert 'total' in codes

    def test_dict_carry_forward(self, mixed_headers_xlsx):
        """Missing sheet in dict carries forward previous value."""
        # Sheet 1: header=3, Sheet 2: not in dict → carry 3 (wrong for this sheet),
        # Sheet 3: header=3
        # Only test sheets 1 and 3 to verify carry-forward works correctly
        d = Dlist.read(mixed_headers_xlsx, format='excel',
                       header_row={1: 3}, sheets=[1, 3])
        assert len(d) == 3  # 2 from sheet 1 + 1 from sheet 3
        items_s1 = [i for i in d if i['tab'] == 'Report']
        items_s3 = [i for i in d if i['tab'] == 'Summary']
        assert len(items_s1) == 2
        assert items_s1[0]['Code'] == 'R01'
        assert len(items_s3) == 1
        assert items_s3[0]['Metric'] == 'total'

    def test_dict_data_start(self, mixed_headers_xlsx):
        """Per-sheet data_start via dict."""
        d = Dlist.read(mixed_headers_xlsx, format='excel',
                       header_row={1: 3, 2: 1, 3: 3},
                       data_start={1: 5},  # skip first data row on sheet 1
                       sheets=[1])
        assert len(d) == 1
        assert d[0]['Code'] == 'R02'

    def test_int_header_row_all_sheets(self, mixed_headers_xlsx):
        """Int header_row applies globally (existing behavior)."""
        d = Dlist.read(mixed_headers_xlsx, format='excel',
                       header_row=3, sheets=[1, 3])
        items_s1 = [i for i in d if i['tab'] == 'Report']
        items_s3 = [i for i in d if i['tab'] == 'Summary']
        assert items_s1[0]['Code'] == 'R01'
        assert items_s3[0]['Metric'] == 'total'

    def test_auto_detect_per_sheet(self, mixed_headers_xlsx):
        """No header_row → auto-detect independently per sheet."""
        d = Dlist.read(mixed_headers_xlsx, format='excel')
        tabs = set(item['tab'] for item in d)
        assert 'Report' in tabs
        assert 'Timeline' in tabs
        assert 'Summary' in tabs


# ─── Merge on duplicate id ───────────────────────────────────

@pytest.fixture
def dup_id_single_sheet_xlsx(tmp_path):
    """Single sheet with duplicate IDs."""
    wb = Workbook()
    ws = wb.active
    ws.title = 'Data'
    ws['A1'] = 'ID'
    ws['B1'] = 'Name'
    ws['C1'] = 'Score'
    ws['A2'] = 'E001'
    ws['B2'] = 'photo1.jpg'
    ws['C2'] = '85'
    ws['A3'] = 'E002'
    ws['B3'] = 'video1.mp4'
    ws['C3'] = '90'
    ws['A4'] = 'E001'       # duplicate id
    ws['B4'] = 'photo1b.jpg'  # different Name
    ws['C4'] = '95'          # different Score

    path = tmp_path / 'dup_single.xlsx'
    wb.save(path)
    return str(path)


@pytest.fixture
def dup_id_multi_sheet_xlsx(tmp_path):
    """Two sheets with overlapping IDs and different columns."""
    wb = Workbook()
    ws1 = wb.active
    ws1.title = 'Evidence'
    ws1['A1'] = 'ID'
    ws1['B1'] = 'Name'
    ws1['A2'] = 'E001'
    ws1['B2'] = 'photo1.jpg'
    ws1['A3'] = 'E002'
    ws1['B3'] = 'video1.mp4'

    ws2 = wb.create_sheet('Analysis')
    ws2['A1'] = 'ID'
    ws2['B1'] = 'Result'
    ws2['A2'] = 'E001'
    ws2['B2'] = 'positive'
    ws2['A3'] = 'E002'
    ws2['B3'] = 'negative'

    path = tmp_path / 'dup_multi.xlsx'
    wb.save(path)
    return str(path)


@pytest.fixture
def dup_id_same_tab_twice_xlsx(tmp_path):
    """Single sheet where same ID appears 3 times."""
    wb = Workbook()
    ws = wb.active
    ws.title = 'Log'
    ws['A1'] = 'ID'
    ws['B1'] = 'Event'
    ws['A2'] = 'X01'
    ws['B2'] = 'created'
    ws['A3'] = 'X01'
    ws['B3'] = 'modified'
    ws['A4'] = 'X01'
    ws['B4'] = 'deleted'

    path = tmp_path / 'dup_triple.xlsx'
    wb.save(path)
    return str(path)


class TestMergeOnId:
    def test_single_sheet_first_wins(self, dup_id_single_sheet_xlsx):
        """Duplicate id in same sheet: first values win."""
        d = Dlist.read(dup_id_single_sheet_xlsx, format='excel', id_='ID')
        assert len(d) == 2
        assert d['E001']['Name'] == 'photo1.jpg'  # first wins
        assert d['E001']['Score'] == '85'          # first wins

    def test_single_sheet_row_is_dict(self, dup_id_single_sheet_xlsx):
        """Row becomes a dict {tab: [rows]} for duplicates in same sheet."""
        d = Dlist.read(dup_id_single_sheet_xlsx, format='excel', id_='ID')
        row = d['E001']['row']
        assert isinstance(row, dict)
        assert 'Data' in row
        # Two occurrences in same tab → list
        assert isinstance(row['Data'], list)
        assert row['Data'] == ['2', '4']

    def test_single_sheet_unique_row_is_dict(self, dup_id_single_sheet_xlsx):
        """Non-duplicate id still gets row as dict when merge is active."""
        d = Dlist.read(dup_id_single_sheet_xlsx, format='excel', id_='ID')
        row = d['E002']['row']
        assert isinstance(row, dict)
        assert row['Data'] == '3'

    def test_multi_sheet_merge_fields(self, dup_id_multi_sheet_xlsx):
        """Same id across sheets: fields from second sheet are added."""
        d = Dlist.read(dup_id_multi_sheet_xlsx, format='excel', id_='ID')
        assert len(d) == 2
        # First sheet fields
        assert d['E001']['Name'] == 'photo1.jpg'
        # Second sheet fields merged in
        assert d['E001']['Result'] == 'positive'

    def test_multi_sheet_row_dict_keys(self, dup_id_multi_sheet_xlsx):
        """Row dict has entries for each tab."""
        d = Dlist.read(dup_id_multi_sheet_xlsx, format='excel', id_='ID')
        row = d['E001']['row']
        assert isinstance(row, dict)
        assert 'Evidence' in row
        assert 'Analysis' in row
        assert row['Evidence'] == '2'
        assert row['Analysis'] == '2'

    def test_multi_sheet_tab_field(self, dup_id_multi_sheet_xlsx):
        """Tab field lists all tabs where the id appeared."""
        d = Dlist.read(dup_id_multi_sheet_xlsx, format='excel', id_='ID')
        tab = d['E001']['tab']
        assert isinstance(tab, list)
        assert 'Evidence' in tab
        assert 'Analysis' in tab

    def test_multi_sheet_single_tab_record(self, dup_id_multi_sheet_xlsx):
        """If a record only appears in one tab, tab is a string."""
        # Create a scenario: parse only sheet 1, but with id_ set
        d = Dlist.read(dup_id_multi_sheet_xlsx, format='excel',
                       id_='ID', sheets=[1])
        # Single sheet → no tab field
        assert 'tab' not in d['E001']

    def test_triple_dup_same_tab(self, dup_id_same_tab_twice_xlsx):
        """Three occurrences of same id → row list has 3 entries."""
        d = Dlist.read(dup_id_same_tab_twice_xlsx, format='excel', id_='ID')
        assert len(d) == 1
        assert d['X01']['Event'] == 'created'  # first wins
        row = d['X01']['row']
        assert isinstance(row['Log'], list)
        assert len(row['Log']) == 3
        assert row['Log'] == ['2', '3', '4']

    def test_no_merge_without_id(self, dup_id_single_sheet_xlsx):
        """Without id_, duplicates are kept as separate records."""
        d = Dlist.read(dup_id_single_sheet_xlsx, format='excel')
        assert len(d) == 3  # all rows kept
        assert d[0]['row'] == '2'  # plain string, not dict

    def test_order_preserved(self, dup_id_multi_sheet_xlsx):
        """Merged records preserve first-seen order."""
        d = Dlist.read(dup_id_multi_sheet_xlsx, format='excel', id_='ID')
        assert d[0]['ID'] == 'E001'
        assert d[1]['ID'] == 'E002'


# ─── Edge cases ─────────────────────────────────────────────

class TestEdgeCases:
    def test_nonexistent_file(self):
        with pytest.raises(ValueError):
            Dlist.read('/tmp/nonexistent_file.xlsx', format='excel')

    def test_id_field(self, simple_xlsx):
        d = Dlist.read(simple_xlsx, format='excel', id_='Name')
        assert d.id == 'Name'
        assert d['alpha']['Value'] == '10'

    def test_with_id_on_column(self, multi_sheet_xlsx):
        d = Dlist.read(multi_sheet_xlsx, format='excel', id_='ID', sheets=[1])
        assert d.id == 'ID'
        assert d['E001']['Name'] == 'photo1.jpg'
