"""
:mod:`tests.unit.file.test_u_file_base` module.

Unit tests for :mod:`etlplus.file.base`.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import FrozenInstanceError
from pathlib import Path
from typing import Any
from typing import cast

import pytest

from etlplus.file import FileFormat
from etlplus.file.base import ArchiveWrapperFileHandlerABC
from etlplus.file.base import DelimitedTextFileHandlerABC
from etlplus.file.base import EmbeddedDatabaseFileHandlerABC
from etlplus.file.base import FileHandlerABC
from etlplus.file.base import ReadOnlyFileHandlerABC
from etlplus.file.base import ReadOnlySpreadsheetFileHandlerABC
from etlplus.file.base import ReadOptions
from etlplus.file.base import ScientificDatasetFileHandlerABC
from etlplus.file.base import SingleDatasetScientificFileHandlerABC
from etlplus.file.base import SpreadsheetFileHandlerABC
from etlplus.file.base import TextFixedWidthFileHandlerABC
from etlplus.file.base import WriteOptions
from etlplus.file.csv import CsvFile
from etlplus.file.dat import DatFile
from etlplus.file.dta import DtaFile
from etlplus.file.fwf import FwfFile
from etlplus.file.nc import NcFile
from etlplus.file.ods import OdsFile
from etlplus.file.psv import PsvFile
from etlplus.file.rda import RdaFile
from etlplus.file.rds import RdsFile
from etlplus.file.sav import SavFile
from etlplus.file.sqlite import SqliteFile
from etlplus.file.tab import TabFile
from etlplus.file.tsv import TsvFile
from etlplus.file.txt import TxtFile
from etlplus.file.xls import XlsFile
from etlplus.file.xlsm import XlsmFile
from etlplus.file.xlsx import XlsxFile
from etlplus.file.xpt import XptFile
from etlplus.types import JSONData
from etlplus.types import JSONList

# SECTION: HELPERS ========================================================== #


_NO_DEFAULT: object = object()


def _assert_single_dataset_rejects_non_default_key(
    handler: SingleDatasetScientificFileHandlerABC,
    *,
    suffix: str,
) -> None:
    """Assert single-dataset scientific handlers reject non-default keys."""
    bad_dataset = 'not_default_dataset'
    path = Path(f'ignored.{suffix}')
    for operation in ('read', 'write'):
        with pytest.raises(ValueError, match='supports only dataset key'):
            if operation == 'read':
                handler.read_dataset(path, dataset=bad_dataset)
            else:
                handler.write_dataset(path, [], dataset=bad_dataset)


_NAMING_METHOD_CASES: list[tuple[str, str, str]] = [
    ('delimited_handlers', 'read_rows', 'write_rows'),
    ('spreadsheet_handlers', 'read_sheet', 'write_sheet'),
    ('embedded_db_handlers', 'read_table', 'write_table'),
    ('scientific_handlers', 'read_dataset', 'write_dataset'),
]

_SINGLE_DATASET_SUFFIX_BY_FORMAT: dict[FileFormat, str] = {
    FileFormat.DTA: 'dta',
    FileFormat.NC: 'nc',
    FileFormat.SAV: 'sav',
    FileFormat.XPT: 'xpt',
}


class _ArchiveStub(ArchiveWrapperFileHandlerABC):
    """Concrete archive handler used for option helper tests."""

    format = FileFormat.ZIP

    def read(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONData:
        _ = path
        return {'inner_name': self.inner_name_from_read_options(options)}

    def read_inner_bytes(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> bytes:
        _ = path
        _ = options
        return b''

    def write(
        self,
        path: Path,
        data: JSONData,
        *,
        options: WriteOptions | None = None,
    ) -> int:
        _ = path
        _ = data
        _ = self.inner_name_from_write_options(options)
        return 1

    def write_inner_bytes(
        self,
        path: Path,
        payload: bytes,
        *,
        options: WriteOptions | None = None,
    ) -> None:
        _ = path
        _ = payload
        _ = options


class _DelimitedStub(DelimitedTextFileHandlerABC):
    """Concrete delimited handler used for abstract contract tests."""

    format = FileFormat.CSV
    delimiter = ','

    def read(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONData:
        return self.read_rows(path, options=options)

    def write(
        self,
        path: Path,
        data: JSONData,
        *,
        options: WriteOptions | None = None,
    ) -> int:
        rows: JSONList = data if isinstance(data, list) else [data]
        return self.write_rows(path, rows, options=options)

    def read_rows(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONList:
        _ = path
        _ = options
        return [{'id': 1}]

    def write_rows(
        self,
        path: Path,
        rows: JSONList,
        *,
        options: WriteOptions | None = None,
    ) -> int:
        _ = path
        _ = options
        return len(rows)


class _EmbeddedDbStub(EmbeddedDatabaseFileHandlerABC):
    """Concrete embedded-db handler used for option helper tests."""

    format = FileFormat.SQLITE

    def connect(
        self,
        path: Path,
    ) -> Any:
        _ = path
        return object()

    def list_tables(
        self,
        connection: Any,
    ) -> list[str]:
        _ = connection
        return ['data']

    def read(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONData:
        _ = path
        return [{'table': self.table_from_read_options(options)}]

    def read_table(
        self,
        connection: Any,
        table: str,
    ) -> JSONList:
        _ = connection
        _ = table
        return []

    def write(
        self,
        path: Path,
        data: JSONData,
        *,
        options: WriteOptions | None = None,
    ) -> int:
        _ = path
        _ = data
        _ = self.table_from_write_options(options)
        return 1

    def write_table(
        self,
        connection: Any,
        table: str,
        rows: JSONList,
    ) -> int:
        _ = connection
        _ = table
        return len(rows)


class _ReadOnlySpreadsheetStub(ReadOnlySpreadsheetFileHandlerABC):
    """Minimal concrete read-only spreadsheet handler for contract checks."""

    format = FileFormat.XLS
    engine_name = 'xlrd'

    def read(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONData:
        sheet = self.sheet_from_read_options(options)
        return self.read_sheet(path, sheet=sheet, options=options)

    def read_sheet(
        self,
        path: Path,
        *,
        sheet: str | int,
        options: ReadOptions | None = None,
    ) -> JSONList:
        _ = path
        _ = options
        return [{'sheet': sheet}]


class _ReadOnlyStub(ReadOnlyFileHandlerABC):
    """Minimal concrete read-only handler for contract checks."""

    format = FileFormat.XLS

    def read(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONData:
        _ = path
        _ = options
        return []


class _SpreadsheetStub(SpreadsheetFileHandlerABC):
    """Concrete spreadsheet handler used for option helper tests."""

    format = FileFormat.XLSX
    engine_name = 'stub'

    def read(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONData:
        _ = path
        return [{'sheet': self.sheet_from_read_options(options)}]

    def read_sheet(
        self,
        path: Path,
        *,
        sheet: str | int,
        options: ReadOptions | None = None,
    ) -> JSONList:
        _ = path
        _ = options
        return [{'sheet': sheet}]

    def write(
        self,
        path: Path,
        data: JSONData,
        *,
        options: WriteOptions | None = None,
    ) -> int:
        _ = path
        _ = data
        _ = self.sheet_from_write_options(options)
        return 1

    def write_sheet(
        self,
        path: Path,
        rows: JSONList,
        *,
        sheet: str | int,
        options: WriteOptions | None = None,
    ) -> int:
        _ = path
        _ = sheet
        _ = options
        return len(rows)


class _TextFixedWidthStub(TextFixedWidthFileHandlerABC):
    """Concrete text/fixed-width handler used for abstract contract tests."""

    format = FileFormat.TXT

    def read(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONData:
        return self.read_rows(path, options=options)

    def read_rows(
        self,
        path: Path,
        *,
        options: ReadOptions | None = None,
    ) -> JSONList:
        _ = path
        _ = options
        return [{'text': 'ok'}]

    def write(
        self,
        path: Path,
        data: JSONData,
        *,
        options: WriteOptions | None = None,
    ) -> int:
        rows: JSONList = data if isinstance(data, list) else [data]
        return self.write_rows(path, rows, options=options)

    def write_rows(
        self,
        path: Path,
        rows: JSONList,
        *,
        options: WriteOptions | None = None,
    ) -> int:
        _ = path
        _ = options
        return len(rows)


# SECTION: TESTS ============================================================ #


class TestBaseAbcContracts:
    """Unit tests for abstract base class contracts."""

    def test_file_handler_abc_cannot_be_instantiated(self) -> None:
        """Test FileHandlerABC remaining abstract."""
        with pytest.raises(TypeError):
            FileHandlerABC()  # type: ignore[abstract]

    def test_delimited_abc_requires_row_methods(self) -> None:
        """Test DelimitedTextFileHandlerABC requiring row-level methods."""

        class _IncompleteDelimited(DelimitedTextFileHandlerABC):
            format = FileFormat.CSV
            delimiter = ','

            def read(
                self,
                path: Path,
                *,
                options: ReadOptions | None = None,
            ) -> JSONData:
                _ = path
                _ = options
                return []

            def write(
                self,
                path: Path,
                data: JSONData,
                *,
                options: WriteOptions | None = None,
            ) -> int:
                _ = path
                _ = data
                _ = options
                return 0

        with pytest.raises(TypeError):
            _IncompleteDelimited()  # type: ignore[abstract]

    def test_delimited_concrete_subclass_satisfies_contract(self) -> None:
        """Test a concrete delimited subclass being fully instantiable."""
        handler = _DelimitedStub()

        assert handler.category == 'tabular_delimited_text'
        assert handler.read(Path('ignored.csv')) == [{'id': 1}]
        assert handler.write(Path('ignored.csv'), [{'id': 1}, {'id': 2}]) == 2

    def test_read_only_handler_rejects_write(self) -> None:
        """Test read-only handlers raising on write."""
        handler = _ReadOnlyStub()

        with pytest.raises(RuntimeError, match='read-only'):
            handler.write(Path('ignored.xls'), [{'a': 1}])

    def test_read_only_spreadsheet_handler_rejects_sheet_write(self) -> None:
        """Test read-only spreadsheet handlers rejecting write_sheet calls."""
        handler = _ReadOnlySpreadsheetStub()

        with pytest.raises(RuntimeError, match='read-only'):
            handler.write_sheet(
                Path('ignored.xls'),
                [{'a': 1}],
                sheet=0,
            )

    def test_text_fixed_width_abc_requires_row_methods(self) -> None:
        """Test TextFixedWidthFileHandlerABC requiring row-level methods."""

        class _IncompleteText(TextFixedWidthFileHandlerABC):
            format = FileFormat.TXT

            def read(
                self,
                path: Path,
                *,
                options: ReadOptions | None = None,
            ) -> JSONData:
                _ = path
                _ = options
                return []

            def write(
                self,
                path: Path,
                data: JSONData,
                *,
                options: WriteOptions | None = None,
            ) -> int:
                _ = path
                _ = data
                _ = options
                return 0

        with pytest.raises(TypeError):
            _IncompleteText()  # type: ignore[abstract]

    def test_text_fixed_width_concrete_subclass_satisfies_contract(
        self,
    ) -> None:
        """Test a concrete text/fixed-width subclass being instantiable."""
        handler = _TextFixedWidthStub()

        assert handler.category == 'text_fixed_width'
        assert handler.read(Path('ignored.txt')) == [{'text': 'ok'}]
        assert handler.write(Path('ignored.txt'), [{'text': 'ok'}]) == 1


class TestNamingConventions:
    """Unit tests for category-level internal naming conventions."""

    delimited_handlers = [
        CsvFile,
        DatFile,
        PsvFile,
        TabFile,
        TsvFile,
        TxtFile,
        FwfFile,
    ]
    spreadsheet_handlers = [
        XlsFile,
        XlsxFile,
        XlsmFile,
        OdsFile,
    ]
    embedded_db_handlers = [SqliteFile]
    scientific_handlers = [
        DtaFile,
        NcFile,
        RdaFile,
        RdsFile,
        SavFile,
        XptFile,
    ]

    @pytest.mark.parametrize(
        ('handlers_attr', 'read_method', 'write_method'),
        _NAMING_METHOD_CASES,
        ids=['delimited', 'spreadsheet', 'embedded_db', 'scientific'],
    )
    def test_handlers_expose_category_methods(
        self,
        handlers_attr: str,
        read_method: str,
        write_method: str,
    ) -> None:
        """Test handlers exposing category-level read/write methods."""
        handlers = cast(
            list[type[FileHandlerABC]],
            getattr(self, handlers_attr),
        )
        for handler_cls in handlers:
            assert callable(getattr(handler_cls, 'read', None))
            assert callable(getattr(handler_cls, 'write', None))
            assert callable(getattr(handler_cls, read_method, None))
            assert callable(getattr(handler_cls, write_method, None))


class TestOptionsContracts:
    """Unit tests for base option data classes."""

    @staticmethod
    def _make_handler(handler_kind: str) -> object:
        """Build handler stubs used by option-helper contract tests."""
        match handler_kind:
            case 'delimited':
                return _DelimitedStub()
            case 'read_only':
                return _ReadOnlyStub()
            case 'archive':
                return _ArchiveStub()
            case 'spreadsheet':
                return _SpreadsheetStub()
            case 'embedded':
                return _EmbeddedDbStub()
            case _:
                raise ValueError(f'Unknown handler kind: {handler_kind}')

    def test_dataset_option_helpers_use_override_then_default(self) -> None:
        """Test scientific dataset helpers using explicit then default data."""
        handler = DtaFile()
        cases = (
            (
                handler.dataset_from_read_options,
                handler.resolve_read_dataset,
                ReadOptions(dataset='features'),
            ),
            (
                handler.dataset_from_write_options,
                handler.resolve_write_dataset,
                WriteOptions(dataset='labels'),
            ),
        )
        for from_options, resolve, options in cases:
            expected = cast(str, options.dataset)
            from_options_method = cast(Callable[..., object], from_options)
            resolve_method = cast(Callable[..., object], resolve)
            assert from_options_method(None) is None
            assert from_options_method(options) == expected
            assert resolve_method(None, options=options) == expected
            assert resolve_method('explicit', options=options) == 'explicit'
            assert resolve_method(None, default='fallback') == 'fallback'

    @pytest.mark.parametrize(
        (
            'handler_kind',
            'read_method_name',
            'write_method_name',
            'read_options',
            'write_options',
            'baseline',
            'read_expected',
            'write_expected',
            'read_default',
            'write_default',
        ),
        [
            (
                'delimited',
                'delimiter_from_read_options',
                'delimiter_from_write_options',
                ReadOptions(extras={'delimiter': '|'}),
                WriteOptions(extras={'delimiter': '\t'}),
                ',',
                '|',
                '\t',
                ';',
                ':',
            ),
            (
                'read_only',
                'encoding_from_read_options',
                'encoding_from_write_options',
                ReadOptions(encoding='latin-1'),
                WriteOptions(encoding='utf-16'),
                'utf-8',
                'latin-1',
                'utf-16',
                'utf-16',
                'ascii',
            ),
            (
                'archive',
                'inner_name_from_read_options',
                'inner_name_from_write_options',
                ReadOptions(inner_name='data.json'),
                WriteOptions(inner_name='payload.csv'),
                None,
                'data.json',
                'payload.csv',
                _NO_DEFAULT,
                _NO_DEFAULT,
            ),
            (
                'spreadsheet',
                'sheet_from_read_options',
                'sheet_from_write_options',
                ReadOptions(sheet='Sheet2'),
                WriteOptions(sheet=3),
                0,
                'Sheet2',
                3,
                'fallback_sheet',
                99,
            ),
            (
                'embedded',
                'table_from_read_options',
                'table_from_write_options',
                ReadOptions(table='events'),
                WriteOptions(table='staging'),
                None,
                'events',
                'staging',
                'fallback_table',
                'fallback_table',
            ),
        ],
        ids=['delimiter', 'encoding', 'inner_name', 'sheet', 'table'],
    )
    def test_option_helper_pairs_use_override_then_default(
        self,
        handler_kind: str,
        read_method_name: str,
        write_method_name: str,
        read_options: ReadOptions,
        write_options: WriteOptions,
        baseline: object,
        read_expected: object,
        write_expected: object,
        read_default: object,
        write_default: object,
    ) -> None:
        """Test paired read/write option helpers with override/default."""
        handler = self._make_handler(handler_kind)
        read_call = cast(
            Callable[..., object],
            getattr(handler, read_method_name),
        )
        write_call = cast(
            Callable[..., object],
            getattr(handler, write_method_name),
        )

        assert read_call(None) == baseline
        assert write_call(None) == baseline
        assert read_call(read_options) == read_expected
        assert write_call(write_options) == write_expected
        if read_default is not _NO_DEFAULT:
            assert read_call(None, default=read_default) == read_default
        if write_default is not _NO_DEFAULT:
            assert write_call(None, default=write_default) == write_default

    def test_read_options_use_independent_extras_dicts(self) -> None:
        """Test each ReadOptions instance getting its own extras dict."""
        first = ReadOptions()
        second = ReadOptions()

        assert not first.extras
        assert not second.extras
        assert first.extras is not second.extras

    def test_root_tag_option_helper_use_override_then_default(self) -> None:
        """Test root-tag helper using explicit values then defaults."""
        handler = _ReadOnlyStub()
        assert handler.root_tag_from_write_options(None) == 'root'
        assert (
            handler.root_tag_from_write_options(WriteOptions(root_tag='items'))
            == 'items'
        )
        assert (
            handler.root_tag_from_write_options(None, default='dataset')
            == 'dataset'
        )

    def test_write_options_are_frozen(self) -> None:
        """Test WriteOptions immutability contract."""
        options = WriteOptions()

        with pytest.raises(FrozenInstanceError):
            options.encoding = 'latin-1'  # type: ignore[misc]


class TestScientificDatasetContracts:
    """Unit tests for scientific dataset handler contracts."""

    scientific_handlers = [
        DtaFile,
        NcFile,
        RdaFile,
        RdsFile,
        SavFile,
        XptFile,
    ]
    single_dataset_handlers = [
        DtaFile,
        NcFile,
        SavFile,
        XptFile,
    ]

    def test_handlers_use_scientific_dataset_abc(self) -> None:
        """Test scientific handlers inheriting ScientificDataset ABC."""
        for handler_cls in self.scientific_handlers:
            assert issubclass(handler_cls, ScientificDatasetFileHandlerABC)
            assert handler_cls.dataset_key == 'data'

    def test_single_dataset_handlers_use_single_dataset_scientific_abc(
        self,
    ) -> None:
        """Test single-dataset handlers inheriting the subtype ABC."""
        for handler_cls in self.single_dataset_handlers:
            assert issubclass(
                handler_cls,
                SingleDatasetScientificFileHandlerABC,
            )

    def test_single_dataset_handlers_reject_unknown_dataset_key(self) -> None:
        """Test single-dataset scientific handlers rejecting unknown keys."""
        for handler_cls in self.single_dataset_handlers:
            concrete_cls = cast(type[Any], handler_cls)
            handler = cast(
                SingleDatasetScientificFileHandlerABC,
                concrete_cls(),
            )
            file_format = cast(FileFormat, handler_cls.format)
            suffix = _SINGLE_DATASET_SUFFIX_BY_FORMAT.get(
                file_format,
                file_format.value,
            )
            _assert_single_dataset_rejects_non_default_key(
                handler,
                suffix=suffix,
            )
