#!/usr/bin/env python3
"""Сводка продаж по месяцам и регионам + график.

Скрипт принимает путь к JSON с продажами и путь к выходной папке,
считает сводку по месяцам и регионам и строит график.

Исходный JSON (данные вымышлены):
    {
      "fictional": true,
      "unit": "условные единицы",
      "rows": [
        {"month": "2026-01", "region": "Север", "revenue": 200, "expenses": 140},
        ...
      ]
    }

Что создаётся в выходной папке (имя набора = имя входного файла без .json):
    <набор>-сводка.json   — сводка, итоги и результаты контроля
    <набор>-сводка.xlsx   — исходные данные, сводная таблица, данные графика и график Excel
    <набор>-график.png    — график выручки, расходов и прибыли по месяцам

Матрица месяц x регион может быть неполной: если для месяца нет строки по региону,
в сводке эта ячейка остаётся пустой (в JSON — null), а на графике столбец не рисуется.
Итоги при этом считаются только по имеющимся строкам; в сводке указывается число
отсутствующих комбинаций.

Запуск:
    .venv-pptx/bin/python "сборка/сводка-продажи.py" <путь к JSON> <выходная папка>

Примеры:
    .venv-pptx/bin/python "сборка/сводка-продажи.py" "маршруты/данные/продажи.json" "результаты/сводка-продажи"
    .venv-pptx/bin/python "сборка/сводка-продажи.py" "маршруты/данные/продажи-обновление.json" "результаты/сводка-продажи"

Требования: Python 3.9+, openpyxl, matplotlib.
"""

from __future__ import annotations

import json
import os
import sys
import tempfile
from collections import OrderedDict
from pathlib import Path

# matplotlib пишет кэш шрифтов в ~/.matplotlib; в ограниченной среде это может быть
# недоступно, поэтому направляем кэш в системную временную папку.
os.environ.setdefault("MPLCONFIGDIR", tempfile.mkdtemp(prefix="mplconfig-"))

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter

FIELDS = (("Выручка", "revenue"), ("Расходы", "expenses"), ("Прибыль", "profit"))
REGION_COLORS = ("#2E75B6", "#ED7D31", "#70AD47", "#FFC000", "#7030A0", "#00B0F0")

HEADER_FONT = Font(bold=True)
HEADER_FILL = PatternFill("solid", fgColor="DDEBF7")
TOTAL_FONT = Font(bold=True)


# --- Ввод данных ---------------------------------------------------------

def load_rows(path: Path) -> tuple[str, list[dict]]:
    """Читает JSON и проверяет обязательные поля строк."""
    if not path.is_file():
        raise SystemExit(f"файл не найден: {path}")
    payload = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(payload, dict) or not isinstance(payload.get("rows"), list):
        raise SystemExit(f"ожидался объект с полем 'rows': {path}")
    unit = str(payload.get("unit", ""))
    rows = payload["rows"]
    if not rows:
        raise SystemExit(f"в файле нет ни одной строки данных: {path}")
    for index, row in enumerate(rows, start=1):
        for field in ("month", "region", "revenue", "expenses"):
            if field not in row:
                raise SystemExit(f"строка {index}: нет обязательного поля {field!r}: {row}")
        if not isinstance(row["revenue"], (int, float)) or not isinstance(row["expenses"], (int, float)):
            raise SystemExit(f"строка {index}: revenue и expenses должны быть числами: {row}")
    return unit, rows


def aggregate(rows: list[dict], key: str) -> "OrderedDict[str, dict]":
    """Суммирует выручку и расходы по значению поля key, считает прибыль."""
    totals: "OrderedDict[str, dict]" = OrderedDict()
    for row in rows:
        bucket = totals.setdefault(row[key], {"revenue": 0, "expenses": 0})
        bucket["revenue"] += row["revenue"]
        bucket["expenses"] += row["expenses"]
    for bucket in totals.values():
        bucket["profit"] = bucket["revenue"] - bucket["expenses"]
    return totals


def check_totals(rows: list[dict], by_month: dict, by_region: dict, grand: dict) -> "OrderedDict[str, bool]":
    """Контроль сходимости итогов."""
    checks: "OrderedDict[str, bool]" = OrderedDict()
    checks["выручка: сумма по месяцам = сумма по регионам"] = (
        sum(v["revenue"] for v in by_month.values())
        == sum(v["revenue"] for v in by_region.values())
    )
    checks["расходы: сумма по месяцам = сумма по регионам"] = (
        sum(v["expenses"] for v in by_month.values())
        == sum(v["expenses"] for v in by_region.values())
    )
    checks["прибыль = выручка - расходы"] = grand["profit"] == grand["revenue"] - grand["expenses"]
    checks["прибыль: сумма по регионам = итог"] = (
        sum(v["profit"] for v in by_region.values()) == grand["profit"]
    )
    checks["итоги сходятся с исходными строками"] = (
        grand["revenue"] == sum(r["revenue"] for r in rows)
        and grand["expenses"] == sum(r["expenses"] for r in rows)
    )
    return checks


# --- Оформление Excel ----------------------------------------------------

def style_header(ws, row: int, first_col: int, last_col: int) -> None:
    for col in range(first_col, last_col + 1):
        cell = ws.cell(row=row, column=col)
        cell.font = HEADER_FONT
        cell.fill = HEADER_FILL
        cell.alignment = Alignment(horizontal="center")


def style_totals(ws, row: int, first_col: int, last_col: int) -> None:
    for col in range(first_col, last_col + 1):
        ws.cell(row=row, column=col).font = TOTAL_FONT


def autofit(ws, padding: int = 2) -> None:
    for column in ws.columns:
        width = max(len(str(cell.value if cell.value is not None else "")) for cell in column)
        ws.column_dimensions[get_column_letter(column[0].column)].width = width + padding


# --- График --------------------------------------------------------------

def build_chart_png(path: Path, unit: str, months: list[str], regions: list[str],
                    by_month_region: dict, by_month: dict, grand: dict) -> None:
    """График: столбцы выручки по регионам в каждом месяце + линия общей прибыли.

    Если для месяца нет данных по региону, столбец не рисуется.
    """
    figure, axes = plt.subplots(figsize=(11, 6), dpi=150)
    group_width = 0.8
    bar_width = group_width / len(regions)
    positions = range(len(months))

    for index, region in enumerate(regions):
        offset = -group_width / 2 + bar_width * (index + 0.5)
        xs, values = [], []
        for position, month in zip(positions, months):
            value = by_month_region.get((month, region))
            if value is None:
                continue
            xs.append(position + offset)
            values.append(value["revenue"])
        if xs:
            axes.bar(xs, values, bar_width, label=f"Выручка — {region}",
                     color=REGION_COLORS[index % len(REGION_COLORS)])

    axes.plot(list(positions), [by_month[m]["profit"] for m in months],
              color="#C00000", marker="o", linewidth=2, label="Прибыль, всего")

    for position, month in zip(positions, months):
        axes.annotate(str(by_month[month]["revenue"]), (position, by_month[month]["revenue"]),
                      textcoords="offset points", xytext=(0, 4), ha="center", fontsize=8)

    axes.set_title(f"Продажи по месяцам и регионам, {unit}\n"
                   f"Итог: выручка {grand['revenue']}, расходы {grand['expenses']}, "
                   f"прибыль {grand['profit']}")
    axes.set_xlabel("Месяц")
    axes.set_ylabel(f"Значение, {unit}")
    axes.set_xticks(list(positions))
    axes.set_xticklabels(months)
    axes.axhline(0, color="#808080", linewidth=0.8)
    axes.grid(axis="y", linestyle=":", alpha=0.5)
    axes.legend(fontsize=8, ncol=2)
    figure.tight_layout()
    figure.savefig(path)
    plt.close(figure)


# --- Сборка результатов --------------------------------------------------

def main(argv: list[str]) -> int:
    if len(argv) != 3:
        print(__doc__)
        return 2

    source = Path(argv[1]).expanduser().resolve()
    out_dir = Path(argv[2]).expanduser().resolve()
    out_dir.mkdir(parents=True, exist_ok=True)

    dataset = source.stem
    unit, rows = load_rows(source)

    by_month = aggregate(rows, "month")
    by_region = aggregate(rows, "region")
    months = list(by_month)
    regions = list(by_region)

    grand = {
        "revenue": sum(v["revenue"] for v in by_month.values()),
        "expenses": sum(v["expenses"] for v in by_month.values()),
    }
    grand["profit"] = grand["revenue"] - grand["expenses"]

    checks = check_totals(rows, by_month, by_region, grand)
    for name, ok in checks.items():
        print(f"[{'OK' if ok else 'FAIL'}] {name}")
    if not all(checks.values()):
        raise SystemExit("контроль итогов не пройден")

    # Значение показателя по конкретному месяцу и региону.
    # Если строки нет, возвращается None — такая комбинация считается отсутствующей.
    def cell(month: str, region: str, field: str) -> int | None:
        row = next((r for r in rows if r["month"] == month and r["region"] == region), None)
        if row is None:
            return None
        return row["revenue"] - row["expenses"] if field == "profit" else row[field]

    by_month_region = {(m, r): (None if cell(m, r, "revenue") is None else
                                {"revenue": cell(m, r, "revenue"),
                                 "expenses": cell(m, r, "expenses"),
                                 "profit": cell(m, r, "profit")})
                       for m in months for r in regions}

    # Комбинации месяц x регион, которых нет в исходных данных.
    missing = [{"month": m, "region": r} for m in months for r in regions
               if by_month_region[(m, r)] is None]

    # --- Сводка JSON -----------------------------------------------------
    summary = {
        "dataset": dataset,
        "source": str(source),
        "unit": unit,
        "rows": len(rows),
        "months": months,
        "regions": regions,
        "by_month": {m: by_month[m] for m in months},
        "by_region": {r: by_region[r] for r in regions},
        "by_month_region": {f"{m} | {r}": by_month_region[(m, r)] for m in months for r in regions},
        "missing_month_region": missing,
        "totals": grand,
        "checks": checks,
    }
    summary_path = out_dir / f"{dataset}-сводка.json"
    summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    # --- График PNG ------------------------------------------------------
    chart_path = out_dir / f"{dataset}-график.png"
    build_chart_png(chart_path, unit, months, regions, by_month_region, by_month, grand)

    # --- Книга Excel -----------------------------------------------------
    wb = Workbook()

    ws_data = wb.active
    ws_data.title = "Исходные данные"
    ws_data.append([f"Единица измерения: {unit}"])
    ws_data.append(["Строк: %d; пропущенных комбинаций месяц/регион: %d"
                    % (len(rows), len(missing))])
    ws_data.append(["Месяц", "Регион", "Выручка", "Расходы", "Прибыль"])
    for row in rows:
        ws_data.append([row["month"], row["region"], row["revenue"], row["expenses"],
                        row["revenue"] - row["expenses"]])

    # В сводной таблице отсутствующие комбинации показаны прочерком.
    def shown(value):
        return "—" if value is None else value

    ws_pivot = wb.create_sheet("Сводная")
    ws_pivot.append([f"Единица измерения: {unit}"])
    ws_pivot.append(["Регион", "Показатель", *months, "Итого"])
    for region in regions:
        for label, field in FIELDS:
            values = [shown(cell(m, region, field)) for m in months]
            ws_pivot.append([region, label, *values, by_region[region][field]])
        ws_pivot.append([])
    for label, field in FIELDS:
        ws_pivot.append(["Итого", label, *[by_month[m][field] for m in months], grand[field]])
        style_totals(ws_pivot, ws_pivot.max_row, 1, 2 + len(months))
    style_header(ws_data, 3, 1, 5)
    style_header(ws_pivot, 2, 1, 2 + len(months))
    autofit(ws_data)
    autofit(ws_pivot)

    # На график Excel попадают только регионы, у которых есть данные хотя бы за месяц.
    chart_regions = [r for r in regions if any(cell(m, r, "revenue") is not None for m in months)]
    ws_chart = wb.create_sheet("График выручки")
    ws_chart.append([f"Выручка по месяцам, {unit}"])
    ws_chart.append(["Месяц", *chart_regions, "Итого"])
    for m in months:
        ws_chart.append([m, *[shown(cell(m, r, "revenue")) for r in chart_regions],
                         by_month[m]["revenue"]])
    chart = BarChart()
    chart.type = "col"
    chart.grouping = "clustered"
    chart.title = f"Выручка по месяцам, {unit}"
    chart.y_axis.title = "Выручка"
    chart.x_axis.title = "Месяц"
    chart.add_data(Reference(ws_chart, min_col=2, max_col=1 + len(chart_regions),
                             min_row=2, max_row=2 + len(months)), titles_from_data=True)
    chart.set_categories(Reference(ws_chart, min_col=1, min_row=3, max_row=2 + len(months)))
    ws_chart.add_chart(chart, "A7")
    style_header(ws_chart, 2, 1, 2 + len(chart_regions))
    autofit(ws_chart)

    book_path = out_dir / f"{dataset}-сводка.xlsx"
    wb.save(book_path)

    # --- Отчёт в консоль -------------------------------------------------
    print(f"\nНабор: {dataset}")
    print(f"Единица измерения: {unit}")
    print(f"Строк: {len(rows)}; месяцы: {', '.join(months)}; регионы: {', '.join(regions)}")
    for m in months:
        print(f"  {m}: выручка {by_month[m]['revenue']}, расходы {by_month[m]['expenses']}, "
              f"прибыль {by_month[m]['profit']}")
    for r in regions:
        print(f"  {r}: выручка {by_region[r]['revenue']}, расходы {by_region[r]['expenses']}, "
              f"прибыль {by_region[r]['profit']}")
    print(f"ИТОГО: выручка {grand['revenue']}, расходы {grand['expenses']}, прибыль {grand['profit']}")
    if missing:
        print("Отсутствуют данные по комбинациям месяц/регион: "
              + ", ".join(f"{item['month']} / {item['region']}" for item in missing))
    else:
        print("Матрица месяц/регион заполнена полностью.")
    print(f"\nФайлы:\n  {summary_path}\n  {book_path}\n  {chart_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
