"""Excel formatter for dlist

Requires openpyxl: pip install dlist[excel]
"""

import json
from pathlib import Path

from .base import BaseFormatter
from ..helpers import flattenDict, cleanStr


# Default styles (used when no JSON config file is found)
_DEFAULT_STYLES = {
    "header": {
        "font": "Calibri",
        "size": 12,
        "bold": True,
        "color": "FFFFFF",
        "bg_color": "4472C4",
    },
    "data": {
        "font": "Calibri",
        "size": 12,
        "bold": False,
        "color": "000000",
        "bg_color": None,
    },
    "data_alt": {
        "font": "Calibri",
        "size": 12,
        "bold": False,
        "color": "000000",
        "bg_color": None,
    },
    "subcategory": {
        "font": "Calibri",
        "size": 20,
        "bold": True,
        "color": "1D6B3F",
        "bg_color": "FFD700",
    },
}

# Bundled styles file next to this module
_BUNDLED_STYLES = Path(__file__).parent / "excel_styles.json"


def _load_styles(style_file=None):
    """Load styles from a JSON file, falling back to bundled then defaults.

    Parameters:
        style_file: Path to a JSON styles file, or None.

    Returns:
        dict with keys 'header', 'data', 'data_alt', 'subcategory'.
    """
    styles = dict(_DEFAULT_STYLES)

    # Try user-specified file first, then bundled
    for path in [style_file, _BUNDLED_STYLES]:
        if path is not None:
            p = Path(path)
            if p.is_file():
                with open(p, encoding="utf-8") as f:
                    user = json.load(f)
                # Merge per-section (user overrides defaults)
                for section in styles:
                    if section in user:
                        styles[section] = {**styles[section], **user[section]}
                break

    return styles


def _make_font(cfg):
    """Create an openpyxl Font from a style config dict."""
    from openpyxl.styles import Font

    return Font(
        name=cfg.get("font", "Calibri"),
        size=cfg.get("size", 10),
        bold=cfg.get("bold", False),
        color=cfg.get("color", "000000"),
    )


def _make_fill(cfg):
    """Create an openpyxl PatternFill from a style config dict."""
    from openpyxl.styles import PatternFill

    bg = cfg.get("bg_color")
    if bg:
        return PatternFill(start_color=bg, end_color=bg, fill_type="solid")
    return None


class ExcelFormatter(BaseFormatter):
    """Excel (.xlsx) formatter using openpyxl

    Produces styled Excel workbooks.  Unlike the text-based formatters,
    this writes binary ``.xlsx`` files — multi-column layout (``nCols``)
    and pagination (``page``) do not apply here.

    **Basic mode** (``format`` / ``save``):
        Single worksheet with a styled header row and data rows.
        Alternating row shading (``stripe``) is enabled by default.

    **Categorized mode** (``format_categorized`` / ``save_categorized``):
        One **worksheet tab per top-level category**.  Subcategories
        appear as styled label rows with a blank row before and after.
        Column headers appear only once at the top of each sheet.

    Styles
    ------
    Font, size, bold, color, and background are fully configurable via
    a JSON file with four sections::

        {
            "header":      { "font", "size", "bold", "color", "bg_color" },
            "data":        { ... },
            "data_alt":    { ... },   // alternating row shading
            "subcategory": { ... }    // subcategory label rows
        }

    Style resolution order:

    1. ``style_file`` kwarg — explicit path to a user JSON file
    2. Bundled ``excel_styles.json`` next to this module
    3. Hard-coded defaults (``_DEFAULT_STYLES``)

    Only the fields you include in your JSON are overridden; the rest
    fall back to defaults.

    Parameters (passed as \\*\\*kwargs)::

        keys        – list of column keys to display
        titles      – {key: display_name} for column headers;
                      for categories: {cat_key: format_string} e.g. '{}'
        style_file  – path to a JSON styles file (optional)
        col_width   – column width in characters (default: 15)
        stripe      – alternate row shading (default: True)
        sheet_name  – worksheet name for basic mode (default: 'Data')

    **Basic table**::

        >>> fmt = get_formatter('excel')
        >>> fmt.save(d, 'output.xlsx', keys=['id', 'name', 'type'],
        ...          titles={'id': 'ID', 'name': 'Name', 'type': 'Type'})

    **Categorized** (one tab per top-level category)::

        >>> fmt.save_categorized(d, ['type', 'dir'], ['id', 'name'],
        ...     filepath='output.xlsx',
        ...     titles={'type': 'Type: {}', 'dir': '{}',
        ...             'id': 'ID', 'name': 'Name'})

    **Custom styles**::

        >>> fmt.save(d, 'output.xlsx', keys=['id', 'name'],
        ...          style_file='/path/to/my_styles.json')

    **Via Dlist methods**::

        >>> d.write('output.xlsx', format='excel', keys=['id', 'name'])
        >>> d.write_categorized(['type'], ['id', 'name'],
        ...     filepath='output.xlsx', format='excel')
    """

    def format(self, dlist, keys=[], **kwargs):
        """Build an openpyxl Workbook (returned, not saved).

        Use ``save()`` to write to disk.
        """
        from openpyxl import Workbook

        titles = kwargs.get("titles", {})
        style_file = kwargs.get("style_file")
        col_width = kwargs.get("col_width", 15)
        stripe = kwargs.get("stripe", True)
        styles = _load_styles(style_file)

        wb = Workbook()
        ws = wb.active
        ws.title = kwargs.get("sheet_name", "Data")

        if keys:
            self._write_table(ws, dlist, keys, titles, styles,
                              col_width=col_width, stripe=stripe)

        return wb

    def format_categorized(self, dlist, ctree, keys, titles={}, **kwargs):
        """Build a Workbook with one tab per top-level category."""
        from openpyxl import Workbook

        style_file = kwargs.get("style_file")
        col_width = kwargs.get("col_width", 15)
        stripe = kwargs.get("stripe", True)
        styles = _load_styles(style_file)

        # Complete titles
        for cat in ctree:
            if cat not in titles:
                titles[cat] = "{}"
        for key in keys:
            if key not in titles:
                titles[key] = key

        wb = Workbook()
        # Remove default sheet — we'll create named ones
        wb.remove(wb.active)

        parts = dlist.partition(ctree[0])          # single-pass split
        remainder = parts.pop(None, None)

        # Items missing the top-level category key → first tab
        if remainder and remainder.l:
            if not parts:
                # ALL items lack this key (void grouping) → single tab with title
                tab_name = titles[ctree[0]].format('').strip()
                tab_name = self._safe_sheet_name(tab_name)
                ws = wb.create_sheet(title=tab_name)
                if len(ctree) > 1:
                    self._write_subcategorized(ws, remainder, ctree[1:], keys,
                                               titles, styles,
                                               col_width=col_width, stripe=stripe)
                else:
                    self._write_table(ws, remainder, keys, titles, styles,
                                      col_width=col_width, stripe=stripe)
            else:
                tab_name = self._safe_sheet_name('(other)')
                ws = wb.create_sheet(title=tab_name)
                self._write_table(ws, remainder, keys, titles, styles,
                                  col_width=col_width, stripe=stripe)

        for cat_val, subset in parts.items():
            tab_name = titles[ctree[0]].format(cleanStr(cat_val))
            tab_name = self._safe_sheet_name(tab_name)
            ws = wb.create_sheet(title=tab_name)

            if len(ctree) > 1:
                self._write_subcategorized(ws, subset, ctree[1:], keys,
                                           titles, styles,
                                           col_width=col_width, stripe=stripe)
            else:
                self._write_table(ws, subset, keys, titles, styles,
                                  col_width=col_width, stripe=stripe)

        return wb

    def save(self, dlist, filepath, **kwargs):
        """Save as .xlsx file."""
        wb = self.format(dlist, **kwargs)
        wb.save(filepath)

    def save_categorized(self, dlist, ctree, keys, filepath, titles={}, **kwargs):
        """Save categorized output as .xlsx file."""
        wb = self.format_categorized(dlist, ctree, keys, titles=titles, **kwargs)
        wb.save(filepath)

    # ── Internal helpers ─────────────────────────────────────────────

    def _write_table(self, ws, dlist, keys, titles, styles,
                     col_width=15, stripe=True, start_row=1, header=True):
        """Write header + data rows to a worksheet starting at start_row."""
        from openpyxl.utils import get_column_letter

        header_font = _make_font(styles["header"])
        header_fill = _make_fill(styles["header"])
        data_font = _make_font(styles["data"])
        data_fill = _make_fill(styles["data"])
        alt_font = _make_font(styles["data_alt"])
        alt_fill = _make_fill(styles["data_alt"])

        # Column widths
        for i, k in enumerate(keys, 1):
            ws.column_dimensions[get_column_letter(i)].width = col_width

        row = start_row
        # Header row (optional — skipped when repeating in subcategories)
        if header:
            for col, k in enumerate(keys, 1):
                cell = ws.cell(row=row, column=col, value=titles.get(k, k))
                cell.font = header_font
                if header_fill:
                    cell.fill = header_fill
            row += 1

        # Data rows
        for idx, item in enumerate(dlist.l):
            fitem = flattenDict(item)
            for col, k in enumerate(keys, 1):
                val = cleanStr(fitem[k]) if k in fitem else ""
                cell = ws.cell(row=row, column=col, value=val)
                if stripe and idx % 2 == 1:
                    cell.font = alt_font
                    if alt_fill:
                        cell.fill = alt_fill
                else:
                    cell.font = data_font
                    if data_fill:
                        cell.fill = data_fill
            row += 1

        return row

    def _write_subcategorized(self, ws, dlist, ctree, keys, titles, styles,
                              col_width=15, stripe=True):
        """Write subcategories as label rows with blank row spacing."""
        from openpyxl.utils import get_column_letter

        sub_font = _make_font(styles["subcategory"])
        sub_fill = _make_fill(styles["subcategory"])

        # Column widths
        for i, k in enumerate(keys, 1):
            ws.column_dimensions[get_column_letter(i)].width = col_width

        # Write header only once at the top of the sheet
        row = 1
        header_font = _make_font(styles["header"])
        header_fill = _make_fill(styles["header"])
        for col, k in enumerate(keys, 1):
            cell = ws.cell(row=row, column=col, value=titles.get(k, k))
            cell.font = header_font
            if header_fill:
                cell.fill = header_fill
        row += 1

        sub_key = ctree[0]
        parts = dlist.partition(sub_key)
        remainder = parts.pop(None, None)

        # Items missing this subcategory key → write first (no label)
        if remainder and remainder.l:
            row = self._write_table(ws, remainder, keys, titles, styles,
                                    col_width=col_width, stripe=stripe,
                                    start_row=row, header=False)

        for sub_val, subset in parts.items():
            label = titles.get(sub_key, "{}").format(cleanStr(sub_val))

            # Blank row before subcategory
            row += 1

            # Subcategory label row — column B so the parser can
            # round-trip it (col A empty + col B value = subcategory)
            cell = ws.cell(row=row, column=2, value=label)
            cell.font = sub_font
            if sub_fill:
                cell.fill = sub_fill
            # Apply fill to remaining columns (including A)
            for col in [1] + list(range(3, len(keys) + 1)):
                c = ws.cell(row=row, column=col)
                if sub_fill:
                    c.fill = sub_fill
            row += 1

            # Blank row after subcategory label
            row += 1

            if len(ctree) > 1:
                row = self._write_subcategorized_inner(
                    ws, subset, ctree[1:], keys, titles, styles,
                    col_width=col_width, stripe=stripe, start_row=row)
            else:
                row = self._write_table(ws, subset, keys, titles, styles,
                                        col_width=col_width, stripe=stripe,
                                        start_row=row, header=False)

    def _write_subcategorized_inner(self, ws, dlist, ctree, keys, titles,
                                    styles, col_width=15, stripe=True,
                                    start_row=1):
        """Handle deeper subcategory levels recursively.

        Returns the next available row after all written content.
        """
        sub_font = _make_font(styles["subcategory"])
        sub_fill = _make_fill(styles["subcategory"])

        row = start_row
        sub_key = ctree[0]
        parts = dlist.partition(sub_key)
        remainder = parts.pop(None, None)

        # Items missing this subcategory key → write first (no label)
        if remainder and remainder.l:
            row = self._write_table(ws, remainder, keys, titles, styles,
                                    col_width=col_width, stripe=stripe,
                                    start_row=row, header=False)

        for sub_val, subset in parts.items():
            label = titles.get(sub_key, "{}").format(cleanStr(sub_val))

            if row > start_row:
                row += 1

            # Column B so the parser can round-trip subcategories
            cell = ws.cell(row=row, column=2, value=label)
            cell.font = sub_font
            if sub_fill:
                cell.fill = sub_fill
            for col in [1] + list(range(3, len(keys) + 1)):
                c = ws.cell(row=row, column=col)
                if sub_fill:
                    c.fill = sub_fill
            row += 1
            row += 1

            if len(ctree) > 1:
                row = self._write_subcategorized_inner(
                    ws, subset, ctree[1:], keys, titles, styles,
                    col_width=col_width, stripe=stripe, start_row=row)
            else:
                row = self._write_table(ws, subset, keys, titles, styles,
                                        col_width=col_width, stripe=stripe,
                                        start_row=row, header=False)

        return row

    @staticmethod
    def _safe_sheet_name(name):
        """Sanitize a string for use as an Excel sheet name."""
        # Max 31 chars, no [ ] : * ? / \
        name = name.replace(":", " -")
        for ch in r"[]*?/\\":
            name = name.replace(ch, "_")
        return name[:31]
