"""Excel parser for dlist

Reads .xlsx workbooks into Dlist objects.  Handles multi-line headers,
subcategory rows, and multi-sheet workbooks.

Configuration (all optional, passed as kwargs)::

    header_row   – 1-indexed row containing column names (auto-detected
                   if omitted: first row where ≥50% of cells are non-empty
                   strings)
    data_start   – 1-indexed first data row (default: header_row + 1)
    sheets       – list of 1-indexed sheet numbers to parse; None = all
    id_          – id field for the resulting Dlist (default: '')

Every record gets a ``'row'`` field with its original Excel row number.
If the workbook has more than one parsed sheet, each record also gets a
``'tab'`` field with the sheet name.

Subcategory detection: a row where column A is empty, column B has a
value, and the remaining data columns are mostly empty is treated as a
subcategory label.  Subsequent data rows receive a ``'category'`` field
with that label.  Rows before any subcategory lack the field.

Rows where all data columns are empty are skipped.

Examples::

    >>> d = Dlist.read('report.xlsx', format='excel')
    >>> d = Dlist.read('report.xlsx', format='excel',
    ...               header_row=3, sheets=[1, 2])
"""

import os
from .base import BaseParser


class ExcelParser(BaseParser):
    """Parse Excel workbooks into Dlist objects."""

    # ── header heuristic ────────────────────────────────────────

    @staticmethod
    def _detect_header_row(ws, max_scan=30):
        """Find the header row by scanning for the first row where
        ≥50% of cells contain non-empty string values.

        Parameters:
            ws: openpyxl worksheet
            max_scan: maximum rows to scan

        Returns:
            int: 1-indexed row number, or 1 as fallback
        """
        max_col = ws.max_column or 1
        threshold = max_col / 2

        for row_idx in range(1, min((ws.max_row or 1) + 1, max_scan + 1)):
            str_count = 0
            for col_idx in range(1, max_col + 1):
                val = ws.cell(row=row_idx, column=col_idx).value
                if isinstance(val, str) and val.strip():
                    str_count += 1
            if str_count >= threshold:
                return row_idx

        return 1  # fallback

    # ── subcategory detection ───────────────────────────────────

    @staticmethod
    def _is_subcategory_row(row_cells, data_col_indices):
        """Check if a row is a subcategory label.

        Rule: column A (index 0) is empty, column B (index 1) has a
        non-empty value, and the remaining data columns are mostly empty.

        Parameters:
            row_cells: list of cell values (0-indexed)
            data_col_indices: list of 0-indexed column positions for data

        Returns:
            str|None: subcategory label, or None if not a subcategory row
        """
        if len(row_cells) < 2:
            return None

        col_a = row_cells[0]
        col_b = row_cells[1]

        # Col A must be empty
        if col_a is not None and str(col_a).strip():
            return None

        # Col B must have a value
        if col_b is None or not str(col_b).strip():
            return None

        # Remaining data columns (excluding B) must be mostly empty
        other_filled = 0
        other_cols = [i for i in data_col_indices if i >= 2]
        for i in other_cols:
            if i < len(row_cells):
                val = row_cells[i]
                if val is not None and str(val).strip():
                    other_filled += 1

        # "Mostly empty" = at most 1 non-empty cell in the rest
        if other_filled > 1:
            return None

        return str(col_b).strip()

    # ── config resolution ───────────────────────────────────────

    @staticmethod
    def _resolve_per_sheet(cfg, sheet_num, last_value):
        """Resolve a config value for a given sheet number.

        If *cfg* is an int, return it for every sheet (global).
        If *cfg* is a dict, look up *sheet_num*; if missing, carry
        forward *last_value* (the value used for the previous sheet).
        If *cfg* is None, return None (auto-detect).

        Returns:
            (resolved_value, carry_forward_value)
        """
        if cfg is None:
            return None, last_value
        if isinstance(cfg, int):
            return cfg, cfg
        # dict
        if sheet_num in cfg:
            val = cfg[sheet_num]
            return val, val
        return last_value, last_value

    # ── main parse ──────────────────────────────────────────────

    def parse(self, source, id_='', **kwargs):
        """Parse an Excel file into a Dlist.

        Parameters:
            source (str): Path to .xlsx file
            id_ (str): Optional id field name
            header_row (int|dict): 1-indexed row with column names.
                An int applies to all sheets.  A dict ``{sheet_num: row}``
                sets per-sheet values; missing sheets carry forward the
                last defined value.  ``None`` = auto-detect per sheet.
            data_start (int|dict): 1-indexed first data row.  Same
                int-or-dict semantics as *header_row*.  Defaults to
                ``header_row + 1`` when not specified.
            sheets (list[int]): 1-indexed sheet numbers to parse; None = all

        Returns:
            Dlist: Parsed Dlist object
        """
        try:
            from openpyxl import load_workbook
        except ImportError:
            raise ImportError(
                "openpyxl is required for Excel parsing. "
                "Install it with: pip install openpyxl"
            )
        from ..dlist import Dlist

        if not isinstance(source, str) or not os.path.isfile(source):
            raise ValueError(f"Excel parser requires a file path, got: {source}")

        header_row_cfg = kwargs.get('header_row', None)
        data_start_cfg = kwargs.get('data_start', None)
        sheet_nums = kwargs.get('sheets', None)

        wb = load_workbook(source, data_only=True)
        all_sheets = wb.sheetnames

        # Resolve which sheets to parse, keeping their 1-indexed numbers
        if sheet_nums is not None:
            sheet_indices = []
            for n in sheet_nums:
                if n < 1 or n > len(all_sheets):
                    raise ValueError(
                        f"Sheet number {n} out of range "
                        f"(workbook has {len(all_sheets)} sheets)")
                sheet_indices.append(n)
        else:
            sheet_indices = list(range(1, len(all_sheets) + 1))

        multi_tab = len(sheet_indices) > 1
        records = []

        last_h = None  # carry-forward for header_row dict
        last_d = None  # carry-forward for data_start dict

        for sheet_num in sheet_indices:
            ws = wb[all_sheets[sheet_num - 1]]
            if (ws.max_row or 0) == 0:
                continue

            # Resolve per-sheet config (int, dict, or None)
            h_row, last_h = self._resolve_per_sheet(
                header_row_cfg, sheet_num, last_h)
            d_start, last_d = self._resolve_per_sheet(
                data_start_cfg, sheet_num, last_d)

            # Auto-detect header if not resolved
            if h_row is None:
                h_row = self._detect_header_row(ws)
            if d_start is None:
                d_start = h_row + 1

            # Read column names from header row
            max_col = ws.max_column or 1
            columns = []
            for col_idx in range(1, max_col + 1):
                val = ws.cell(row=h_row, column=col_idx).value
                if val is not None and str(val).strip():
                    columns.append((col_idx, str(val).strip()))

            if not columns:
                continue

            # 0-indexed column positions for data detection
            data_col_indices = [c - 1 for c, _ in columns]

            current_category = None

            for row_idx in range(d_start, (ws.max_row or 0) + 1):
                # Read all cell values for this row (0-indexed list)
                row_vals = []
                for col_idx in range(1, max_col + 1):
                    row_vals.append(ws.cell(row=row_idx, column=col_idx).value)

                # Check for subcategory row
                cat_label = self._is_subcategory_row(row_vals, data_col_indices)
                if cat_label is not None:
                    current_category = cat_label
                    continue

                # Check if row has any data in the identified columns
                has_data = False
                for col_idx, _ in columns:
                    val = row_vals[col_idx - 1] if col_idx - 1 < len(row_vals) else None
                    if val is not None and str(val).strip():
                        has_data = True
                        break

                if not has_data:
                    continue

                # Build record
                record = {}
                for col_idx, col_name in columns:
                    val = row_vals[col_idx - 1] if col_idx - 1 < len(row_vals) else None
                    if val is not None:
                        record[col_name] = str(val).strip() if not isinstance(val, list) else val
                    else:
                        record[col_name] = ''

                record['row'] = str(row_idx)

                if multi_tab:
                    record['tab'] = ws.title

                if current_category is not None:
                    record['category'] = current_category

                records.append(record)

        wb.close()

        # ── merge duplicates when id_ is specified ───────────
        if id_ and records:
            merged = {}  # id_value → record
            row_map = {}  # id_value → {tab: row_or_list}
            order = []    # preserve first-seen order
            # For single-sheet, use the sheet name from the only parsed sheet
            default_tab = all_sheets[sheet_indices[0] - 1] if not multi_tab else None

            for rec in records:
                id_val = rec.get(id_, '')
                tab_key = rec.pop('tab', default_tab)
                row_val = rec.pop('row')

                if id_val not in merged:
                    # First occurrence — keep all fields
                    merged[id_val] = rec
                    row_map[id_val] = {tab_key: row_val}
                    order.append(id_val)
                else:
                    # Duplicate — first wins: only add missing fields
                    for k, v in rec.items():
                        if k not in merged[id_val]:
                            merged[id_val][k] = v
                    # Accumulate row info
                    if tab_key in row_map[id_val]:
                        existing = row_map[id_val][tab_key]
                        if isinstance(existing, list):
                            existing.append(row_val)
                        else:
                            row_map[id_val][tab_key] = [existing, row_val]
                    else:
                        row_map[id_val][tab_key] = row_val

            # Reassemble records with row dict
            records = []
            for id_val in order:
                rec = merged[id_val]
                rec['row'] = row_map[id_val]
                if multi_tab:
                    rec['tab'] = list(row_map[id_val].keys())
                    if len(rec['tab']) == 1:
                        rec['tab'] = rec['tab'][0]
                records.append(rec)

        return Dlist(records, id_=id_)
