"""
CSV transformation helpers: rename columns, add blanks, and simple calculated columns.
"""

from __future__ import annotations

import ast
import csv
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, List, Mapping, Optional, Tuple


@dataclass
class CalculationSpec:
    target: str
    expression: str


def apply_transforms(
    input_path: Path,
    output_path: Path,
    renames: Mapping[str, str] | None = None,
    add_blank: Iterable[str] | None = None,
    calculations: Iterable[str] | None = None,
) -> None:
    """
    Apply column rename, blank column addition, and calculated columns to a CSV.
    """
    rows, headers = _read_csv(input_path)
    rename_map = dict(renames or {})
    add_blank_list = list(add_blank or [])
    calc_specs = [_parse_calc_spec(spec) for spec in (calculations or [])]

    # Rename headers in order
    new_headers: List[str] = [rename_map.get(h, h) for h in headers]

    # Add blanks if missing
    for col in add_blank_list:
        if col not in new_headers:
            new_headers.append(col)

    # Add calculated column headers
    for calc in calc_specs:
        if calc.target not in new_headers:
            new_headers.append(calc.target)

    output_rows: List[Dict[str, str]] = []
    for row in rows:
        out_row: Dict[str, str] = {}

        # Apply renames and copy values
        for original, value in row.items():
            target = rename_map.get(original, original)
            out_row[target] = value

        # Ensure blanks
        for col in add_blank_list:
            out_row.setdefault(col, "")

        # Calculations
        for calc in calc_specs:
            value = _evaluate_expression(calc.expression, out_row)
            out_row[calc.target] = "" if value is None else value

        output_rows.append(out_row)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=new_headers)
        writer.writeheader()
        for row in output_rows:
            writer.writerow({h: row.get(h, "") for h in new_headers})


def _read_csv(path: Path) -> Tuple[List[Dict[str, str]], List[str]]:
    with path.open("r", newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None:
            raise ValueError(f"CSV {path} is missing a header row.")
        rows = [dict(row) for row in reader]
        return rows, list(reader.fieldnames)


def _parse_calc_spec(spec: str) -> CalculationSpec:
    if "=" not in spec:
        raise ValueError(f"Calculation must be in form 'NewCol=expression': {spec}")
    target, expr = spec.split("=", 1)
    target = target.strip()
    expr = expr.strip()
    if not target or not expr:
        raise ValueError(f"Invalid calculation spec: {spec}")
    return CalculationSpec(target=target, expression=expr)


def _evaluate_expression(expr: str, row: Mapping[str, str]) -> Optional[str]:
    """
    Evaluate a simple arithmetic expression against a row, with:
    - column names as variables (numeric only)
    - TODAY constant/function returning Excel-like serial
    Allowed nodes: BinOp, UnaryOp, Name, Constant, Call (for TODAY), expressions only.
    """
    try:
        tree = ast.parse(expr, mode="eval")
    except SyntaxError:
        return None

    allowed_nodes = (
        ast.Expression,
        ast.BinOp,
        ast.UnaryOp,
        ast.Add,
        ast.Sub,
        ast.Mult,
        ast.Div,
        ast.Pow,
        ast.USub,
        ast.UAdd,
        ast.Mod,
        ast.Constant,
        ast.Name,
        ast.Call,
    )

    for node in ast.walk(tree):
        if not isinstance(node, allowed_nodes):
            return None
        if isinstance(node, ast.Call):
            if not isinstance(node.func, ast.Name) or node.func.id.upper() != "TODAY":
                return None
            if node.args:
                return None

    def _today_serial() -> float:
        base = datetime(1899, 12, 30)
        return float((datetime.now().date() - base.date()).days)

    env: Dict[str, float] = {}
    for key, val in row.items():
        num = _to_number(val)
        if num is not None:
            env[key] = num

    env["TODAY"] = _today_serial()

    try:
        value = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}}, env)
    except Exception:
        return None

    # Only return primitive values
    if isinstance(value, (int, float)):
        return str(value)
    return None


def _to_number(value: str | None) -> Optional[float]:
    if value is None:
        return None
    text = str(value).strip()
    if not text:
        return None
    try:
        return float(text)
    except ValueError:
        return None
