"""
Google Sheets chart helpers for g-gremlin.

Provides header-aware chart creation and chart listing utilities.
"""

from __future__ import annotations

from dataclasses import dataclass
import warnings
from typing import Any, Dict, List, Optional, Tuple

from g_gremlin import google_client
from g_gremlin.a1 import col_index_to_letter, parse_a1_range
from g_gremlin.contracts import ChartResult


@dataclass
class ChartSummary:
    chart_id: int
    title: str
    anchor: Optional[str]
    chart_type: str

    def to_dict(self) -> Dict[str, Any]:
        return {
            "chart_id": self.chart_id,
            "title": self.title,
            "anchor": self.anchor,
            "chart_type": self.chart_type,
        }


class ChartSpecBuilder:
    def build(
        self,
        chart_type: str,
        *,
        title: Optional[str],
        header_count: int,
        domain_range: Dict[str, Any],
        series_ranges: List[Dict[str, Any]],
        x_axis_title: Optional[str] = None,
        y_axis_title: Optional[str] = None,
    ) -> Dict[str, Any]:
        if chart_type == "PIE":
            return self._build_pie_chart(
                title=title,
                domain_range=domain_range,
                series_range=series_ranges[0],
            )
        return self._build_basic_chart(
            chart_type=chart_type,
            title=title,
            header_count=header_count,
            domain_range=domain_range,
            series_ranges=series_ranges,
            x_axis_title=x_axis_title,
            y_axis_title=y_axis_title,
        )

    @staticmethod
    def _build_basic_chart(
        *,
        chart_type: str,
        title: Optional[str],
        header_count: int,
        domain_range: Dict[str, Any],
        series_ranges: List[Dict[str, Any]],
        x_axis_title: Optional[str],
        y_axis_title: Optional[str],
    ) -> Dict[str, Any]:
        # Bar charts use BOTTOM_AXIS for series; others use LEFT_AXIS
        target_axis = "BOTTOM_AXIS" if chart_type == "BAR" else "LEFT_AXIS"
        chart: Dict[str, Any] = {
            "basicChart": {
                "chartType": chart_type,
                "legendPosition": "BOTTOM_LEGEND",
                "headerCount": header_count,
                "domains": [
                    {"domain": {"sourceRange": {"sources": [domain_range]}}}
                ],
                "series": [
                    {
                        "series": {"sourceRange": {"sources": [series_range]}},
                        "targetAxis": target_axis,
                    }
                    for series_range in series_ranges
                ],
            }
        }
        axes: List[Dict[str, Any]] = []
        if x_axis_title:
            axes.append({"position": "BOTTOM_AXIS", "title": x_axis_title})
        if y_axis_title:
            axes.append({"position": "LEFT_AXIS", "title": y_axis_title})
        if axes:
            chart["basicChart"]["axis"] = axes
        if title:
            chart["title"] = title
        return chart

    @staticmethod
    def _build_pie_chart(
        *,
        title: Optional[str],
        domain_range: Dict[str, Any],
        series_range: Dict[str, Any],
    ) -> Dict[str, Any]:
        chart: Dict[str, Any] = {
            "pieChart": {
                "legendPosition": "RIGHT_LEGEND",
                "domain": {"sourceRange": {"sources": [domain_range]}},
                "series": {"sourceRange": {"sources": [series_range]}},
            }
        }
        if title:
            chart["title"] = title
        return chart


def add_chart(
    spreadsheet_id: str,
    sheet_name: str,
    chart_type: str,
    *,
    data_range: Optional[str] = None,
    x_col: Optional[str] = None,
    y_col: Optional[str] = None,
    series_cols: Optional[List[str]] = None,
    title: Optional[str] = None,
    x_axis_title: Optional[str] = None,
    y_axis_title: Optional[str] = None,
    anchor_cell: str = "E1",
    replace: bool = False,
) -> ChartResult:
    """
    Add a chart to a Google Sheet tab and return the chart metadata.

    Raises:
        ValueError: for invalid inputs or missing columns.
        HttpError: for Sheets API failures.
    """
    normalized_type = _normalize_chart_type(chart_type)
    _validate_chart_inputs(data_range, x_col, y_col, series_cols, normalized_type)

    service = google_client.get_sheets_service()
    sheet_id = _get_sheet_id(service, spreadsheet_id, sheet_name)

    domain_range, series_ranges, header_count, meta = _resolve_chart_ranges(
        service=service,
        spreadsheet_id=spreadsheet_id,
        sheet_name=sheet_name,
        sheet_id=sheet_id,
        data_range=data_range,
        x_col=x_col,
        y_col=y_col,
        series_cols=series_cols,
        chart_type=normalized_type,
    )

    _warn_on_text_series(
        service=service,
        spreadsheet_id=spreadsheet_id,
        sheet_name=sheet_name,
        meta=meta,
        chart_type=normalized_type,
    )

    anchor = _parse_anchor(anchor_cell, sheet_name)
    anchor_cell_ref = f"{col_index_to_letter(anchor[1])}{anchor[0]}"

    spec = ChartSpecBuilder().build(
        normalized_type,
        title=title,
        header_count=header_count,
        domain_range=domain_range,
        series_ranges=series_ranges,
        x_axis_title=x_axis_title,
        y_axis_title=y_axis_title,
    )

    requests: List[Dict[str, Any]] = []
    replaced = False
    if replace:
        if not title:
            raise ValueError("--replace requires --title so we can match an existing chart.")
        matches = _find_charts_by_title(
            service=service,
            spreadsheet_id=spreadsheet_id,
            sheet_name=sheet_name,
            title=title,
        )
        if matches:
            replaced = True
            for chart_id in matches:
                requests.append({"deleteEmbeddedObject": {"objectId": chart_id}})

    requests.append(
        {
            "addChart": {
                "chart": {
                    "spec": spec,
                    "position": {
                        "overlayPosition": {
                            "anchorCell": {
                                "sheetId": sheet_id,
                                "rowIndex": anchor[0] - 1,
                                "columnIndex": anchor[1] - 1,
                            }
                        }
                    },
                }
            }
        }
    )

    response = (
        service.spreadsheets()
        .batchUpdate(spreadsheetId=spreadsheet_id, body={"requests": requests})
        .execute()
    )

    chart_id = _extract_chart_id(response)
    return ChartResult(
        success=True,
        chart_id=chart_id,
        title=title,
        anchor=anchor_cell_ref,
        replaced=replaced,
    )


def list_charts(spreadsheet_id: str, sheet_name: str) -> List[ChartSummary]:
    """
    List charts in a given sheet tab.
    """
    service = google_client.get_sheets_service()
    charts = _list_chart_metadata(service, spreadsheet_id, sheet_name)
    summaries: List[ChartSummary] = []
    for chart in charts:
        chart_id = chart.get("chartId")
        if chart_id is None:
            continue
        spec = chart.get("spec", {}) or {}
        title = spec.get("title") or ""
        chart_type = _infer_chart_type(spec)
        anchor = _extract_anchor(chart.get("position"))
        summaries.append(
            ChartSummary(
                chart_id=int(chart_id),
                title=title,
                anchor=anchor,
                chart_type=chart_type,
            )
        )
    return summaries


def _normalize_chart_type(chart_type: str) -> str:
    value = (chart_type or "").strip().upper()
    aliases = {
        "COL": "COLUMN",
        "COLUMN": "COLUMN",
        "BAR": "BAR",
        "LINE": "LINE",
        "AREA": "AREA",
        "SCATTER": "SCATTER",
        "PIE": "PIE",
    }
    if value in aliases:
        return aliases[value]
    raise ValueError(
        "Unsupported chart type. Use one of: column, line, pie, scatter, area, bar."
    )


def _validate_chart_inputs(
    data_range: Optional[str],
    x_col: Optional[str],
    y_col: Optional[str],
    series_cols: Optional[List[str]],
    chart_type: str,
) -> None:
    if data_range:
        if x_col or y_col or series_cols:
            raise ValueError("Use either --range or --x-col/--y-col, not both.")
    else:
        if not x_col:
            raise ValueError("--x-col is required when --range is not provided.")
        if not y_col and not series_cols:
            raise ValueError("Provide --y-col or --series-cols for the chart series.")
    if chart_type == "PIE" and data_range is None:
        if not x_col or not (y_col or series_cols):
            raise ValueError("Pie charts require --x-col and --y-col.")


def _resolve_chart_ranges(
    *,
    service,
    spreadsheet_id: str,
    sheet_name: str,
    sheet_id: int,
    data_range: Optional[str],
    x_col: Optional[str],
    y_col: Optional[str],
    series_cols: Optional[List[str]],
    chart_type: str,
) -> Tuple[Dict[str, Any], List[Dict[str, Any]], int, Dict[str, Any]]:
    if data_range:
        parsed = _parse_data_range(data_range, sheet_name)
        start_row = parsed.start_row
        start_col = parsed.start_col
        end_col = parsed.end_col
        if end_col is None:
            raise ValueError("Range must specify an end column (e.g., A:C).")
        if end_col <= start_col:
            raise ValueError("Range must include at least two columns (domain + series).")
        columns = list(range(start_col, end_col + 1))
        end_row = parsed.end_row or _find_last_non_empty_row(
            service=service,
            spreadsheet_id=spreadsheet_id,
            sheet_name=sheet_name,
            columns=columns,
            start_row=start_row,
        )
        domain_col = start_col
        series_cols_idx = list(range(start_col + 1, end_col + 1))
        if chart_type == "PIE":
            series_cols_idx = [start_col + 1]
        domain_range = _grid_range(sheet_id, start_row, end_row, domain_col, domain_col)
        series_ranges = [
            _grid_range(sheet_id, start_row, end_row, col, col) for col in series_cols_idx
        ]
        series_labels = [f"Col {col_index_to_letter(col)}" for col in series_cols_idx]
        meta = {
            "domain_col": domain_col,
            "series_cols_idx": series_cols_idx,
            "start_row": start_row,
            "end_row": end_row,
            "series_labels": series_labels,
            "domain_label": f"Col {col_index_to_letter(domain_col)}",
        }
        return domain_range, series_ranges, 1, meta

    header_map = _get_header_map(service, spreadsheet_id, sheet_name)
    domain_col = _resolve_column_index(header_map, x_col or "")
    series_list = _combine_series(y_col, series_cols)
    series_cols_idx = [_resolve_column_index(header_map, name) for name in series_list]
    if chart_type == "PIE" and len(series_cols_idx) > 1:
        series_cols_idx = [series_cols_idx[0]]
    columns = [domain_col, *series_cols_idx]
    end_row = _find_last_non_empty_row(
        service=service,
        spreadsheet_id=spreadsheet_id,
        sheet_name=sheet_name,
        columns=columns,
        start_row=1,
    )
    domain_range = _grid_range(sheet_id, 1, end_row, domain_col, domain_col)
    series_ranges = [
        _grid_range(sheet_id, 1, end_row, col, col) for col in series_cols_idx
    ]
    meta = {
        "domain_col": domain_col,
        "series_cols_idx": series_cols_idx,
        "start_row": 1,
        "end_row": end_row,
        "series_labels": series_list,
        "domain_label": x_col or f"Col {col_index_to_letter(domain_col)}",
    }
    return domain_range, series_ranges, 1, meta


def _combine_series(y_col: Optional[str], series_cols: Optional[List[str]]) -> List[str]:
    series: List[str] = []
    if y_col:
        series.append(y_col)
    if series_cols:
        for item in series_cols:
            if item and item not in series:
                series.append(item)
    return series


def _parse_data_range(range_ref: str, sheet_name: str):
    normalized = range_ref
    sheet_part = ""
    range_part = range_ref
    if "!" in range_ref:
        sheet_part, range_part = range_ref.split("!", 1)
    if ":" in range_part:
        start_cell, end_cell = range_part.split(":", 1)
    else:
        start_cell, end_cell = range_part, ""
    if start_cell and not any(ch.isdigit() for ch in start_cell):
        start_cell = f"{start_cell}1"
    if sheet_part:
        normalized = f"{sheet_part}!{start_cell}:{end_cell}" if end_cell else f"{sheet_part}!{start_cell}"
    else:
        normalized = f"{start_cell}:{end_cell}" if end_cell else start_cell

    parsed = parse_a1_range(normalized)
    if parsed.sheet_name and parsed.sheet_name.strip("'") != sheet_name:
        raise ValueError("Range sheet name does not match the provided sheet name.")
    if not parsed.sheet_name:
        parsed.sheet_name = sheet_name
    return parsed


def _get_header_map(service, spreadsheet_id: str, sheet_name: str) -> Dict[str, int]:
    range_ref = _tab_reference(sheet_name) + "!1:1"
    resp = (
        service.spreadsheets()
        .values()
        .get(
            spreadsheetId=spreadsheet_id,
            range=range_ref,
            majorDimension="ROWS",
            valueRenderOption="UNFORMATTED_VALUE",
        )
        .execute()
    )
    values = resp.get("values", []) or []
    if not values:
        raise ValueError(f"No header row found in '{sheet_name}'.")
    headers = values[0]
    header_map: Dict[str, int] = {}
    for idx, header in enumerate(headers, start=1):
        key = _normalize_header(header)
        if not key or key in header_map:
            continue
        header_map[key] = idx
    if not header_map:
        raise ValueError(f"No usable headers found in '{sheet_name}'.")
    return header_map


def _resolve_column_index(header_map: Dict[str, int], name: str) -> int:
    key = _normalize_header(name)
    if key in header_map:
        return header_map[key]
    available = ", ".join(sorted(header_map.keys()))
    raise ValueError(f"Column '{name}' not found. Available headers: {available}")


def _normalize_header(value: Any) -> str:
    if value is None:
        return ""
    return str(value).strip().lower()


def _tab_reference(tab_name: str) -> str:
    escaped = tab_name.replace("'", "''")
    return f"'{escaped}'"


def _grid_range(
    sheet_id: int,
    start_row: int,
    end_row: int,
    start_col: int,
    end_col: int,
) -> Dict[str, Any]:
    return {
        "sheetId": sheet_id,
        "startRowIndex": max(start_row - 1, 0),
        "endRowIndex": max(end_row, 0),
        "startColumnIndex": max(start_col - 1, 0),
        "endColumnIndex": max(end_col, 0),
    }


def _parse_anchor(anchor: str, sheet_name: str) -> Tuple[int, int]:
    parsed = parse_a1_range(anchor if "!" in anchor else f"{sheet_name}!{anchor}")
    if parsed.sheet_name and parsed.sheet_name.strip("'") != sheet_name:
        raise ValueError("Anchor sheet name does not match the provided sheet name.")
    return parsed.start_row, parsed.start_col


def _extract_chart_id(response: Dict[str, Any]) -> Optional[int]:
    replies = response.get("replies", []) if isinstance(response, dict) else []
    for reply in replies:
        add = reply.get("addChart") or {}
        chart = add.get("chart") or {}
        chart_id = chart.get("chartId") or add.get("chartId")
        if chart_id is not None:
            try:
                return int(chart_id)
            except (TypeError, ValueError):
                return None
    return None


def _get_sheet_id(service, spreadsheet_id: str, sheet_name: str) -> int:
    resp = (
        service.spreadsheets()
        .get(
            spreadsheetId=spreadsheet_id,
            fields="sheets.properties.sheetId,sheets.properties.title",
        )
        .execute()
    )
    for sheet in resp.get("sheets", []) or []:
        props = sheet.get("properties", {}) or {}
        if props.get("title") == sheet_name:
            return int(props.get("sheetId"))
    raise ValueError(f"Sheet '{sheet_name}' not found.")


def _find_last_non_empty_row(
    *,
    service,
    spreadsheet_id: str,
    sheet_name: str,
    columns: List[int],
    start_row: int,
) -> int:
    if not columns:
        return start_row
    ranges = []
    for col in columns:
        col_letter = col_index_to_letter(col)
        ranges.append(f"{_tab_reference(sheet_name)}!{col_letter}{start_row}:{col_letter}")
    resp = (
        service.spreadsheets()
        .values()
        .batchGet(
            spreadsheetId=spreadsheet_id,
            ranges=ranges,
            majorDimension="ROWS",
            valueRenderOption="UNFORMATTED_VALUE",
        )
        .execute()
    )
    last_row = start_row - 1
    for value_range in resp.get("valueRanges", []) or []:
        values = value_range.get("values", []) or []
        if values:
            last_row = max(last_row, start_row - 1 + len(values))
    return max(last_row, start_row)


def _list_chart_metadata(service, spreadsheet_id: str, sheet_name: str) -> List[Dict[str, Any]]:
    resp = (
        service.spreadsheets()
        .get(
            spreadsheetId=spreadsheet_id,
            fields="sheets.properties.sheetId,sheets.properties.title,sheets.charts.chartId,sheets.charts.spec,sheets.charts.position",
        )
        .execute()
    )
    for sheet in resp.get("sheets", []) or []:
        props = sheet.get("properties", {}) or {}
        if props.get("title") == sheet_name:
            return list(sheet.get("charts", []) or [])
    raise ValueError(f"Sheet '{sheet_name}' not found.")


def _find_charts_by_title(
    *,
    service,
    spreadsheet_id: str,
    sheet_name: str,
    title: str,
) -> List[int]:
    charts = _list_chart_metadata(service, spreadsheet_id, sheet_name)
    target = (title or "").strip().lower()
    matches: List[int] = []
    for chart in charts:
        spec = chart.get("spec", {}) or {}
        chart_title = str(spec.get("title") or "").strip().lower()
        if chart_title == target and chart.get("chartId") is not None:
            matches.append(int(chart["chartId"]))
    return matches


def _infer_chart_type(spec: Dict[str, Any]) -> str:
    if "pieChart" in spec:
        return "PIE"
    basic = spec.get("basicChart") or {}
    if basic:
        return str(basic.get("chartType") or "").upper()
    return "UNKNOWN"


def _extract_anchor(position: Optional[Dict[str, Any]]) -> Optional[str]:
    if not position:
        return None
    overlay = position.get("overlayPosition", {}) or {}
    anchor = overlay.get("anchorCell", {}) or {}
    if not anchor:
        return None
    row_index = anchor.get("rowIndex")
    col_index = anchor.get("columnIndex")
    if row_index is None or col_index is None:
        return None
    try:
        row = int(row_index) + 1
        col = int(col_index) + 1
    except (TypeError, ValueError):
        return None
    return f"{col_index_to_letter(col)}{row}"


__all__ = [
    "ChartSummary",
    "ChartSpecBuilder",
    "add_chart",
    "list_charts",
]


def _warn_on_text_series(
    *,
    service,
    spreadsheet_id: str,
    sheet_name: str,
    meta: Dict[str, Any],
    chart_type: str,
) -> None:
    start_row = int(meta.get("start_row", 1))
    end_row = int(meta.get("end_row", start_row))
    series_cols_idx = list(meta.get("series_cols_idx") or [])
    series_labels = list(meta.get("series_labels") or [])
    domain_col = meta.get("domain_col")
    domain_label = meta.get("domain_label")

    if end_row < start_row or not series_cols_idx:
        return

    text_series = _detect_text_columns(
        service=service,
        spreadsheet_id=spreadsheet_id,
        sheet_name=sheet_name,
        columns=series_cols_idx,
        start_row=max(start_row + 1, 2),
        end_row=end_row,
    )
    if text_series:
        label_map = _map_column_labels(series_cols_idx, series_labels)
        samples = []
        for col_idx, sample in text_series.items():
            label = label_map.get(col_idx, f"Col {col_index_to_letter(col_idx)}")
            samples.append(f"{label} (e.g. '{sample}')")
        message = (
            "Warning: Series columns contain text values; charts may render empty. "
            f"Columns: {', '.join(samples)}. "
            "Consider rewriting data with 'g-gremlin write --user-entered' or converting columns to numbers."
        )
        warnings.warn(message, UserWarning, stacklevel=2)

    if chart_type == "SCATTER" and domain_col:
        text_domain = _detect_text_columns(
            service=service,
            spreadsheet_id=spreadsheet_id,
            sheet_name=sheet_name,
            columns=[domain_col],
            start_row=max(start_row + 1, 2),
            end_row=end_row,
        )
        if text_domain:
            sample = next(iter(text_domain.values()))
            label = domain_label or f"Col {col_index_to_letter(domain_col)}"
            message = (
                "Warning: Scatter charts require numeric X values; "
                f"column '{label}' contains text (e.g. '{sample}')."
            )
            warnings.warn(message, UserWarning, stacklevel=2)


def _detect_text_columns(
    *,
    service,
    spreadsheet_id: str,
    sheet_name: str,
    columns: List[int],
    start_row: int,
    end_row: int,
) -> Dict[int, str]:
    if not columns or end_row < start_row:
        return {}
    ranges = []
    for col in columns:
        col_letter = col_index_to_letter(col)
        ranges.append(f"{_tab_reference(sheet_name)}!{col_letter}{start_row}:{col_letter}{end_row}")
    resp = (
        service.spreadsheets()
        .values()
        .batchGet(
            spreadsheetId=spreadsheet_id,
            ranges=ranges,
            majorDimension="ROWS",
            valueRenderOption="UNFORMATTED_VALUE",
        )
        .execute()
    )
    results: Dict[int, str] = {}
    for idx, value_range in enumerate(resp.get("valueRanges", []) or []):
        col_idx = columns[idx] if idx < len(columns) else None
        if col_idx is None:
            continue
        values = value_range.get("values", []) or []
        for row in values:
            if not row:
                continue
            cell = row[0]
            if isinstance(cell, str) and cell.strip() != "":
                results[col_idx] = cell
                break
    return results


def _map_column_labels(columns: List[int], labels: List[str]) -> Dict[int, str]:
    label_map: Dict[int, str] = {}
    for idx, col in enumerate(columns):
        if idx < len(labels) and labels[idx]:
            label_map[col] = labels[idx]
    return label_map
