#!/usr/bin/env python3
"""
LANGKAH 5 — Proyeksi target: adu tujuh metode, pilih pemenang lewat backtesting,
sajikan jawaban sebagai RENTANG, bukan satu angka.

Masukan: data transaksi mentah (lihat template_data_transaksi.csv). Skrip menjumlahkan
setoran per jenis pajak per tahun berdasarkan TANGGAL BAYAR (bukan tanggal ketetapan).

Tujuh metode yang diadu:
  naif          tahun depan = tahun terakhir
  rerata_tumbuh tahun terakhir × (1 + rata-rata pertumbuhan tahunan)
  cagr          tahun terakhir × (1 + CAGR seluruh deret)
  rerata_3      rata-rata 3 tahun terakhir
  linier        regresi garis lurus terhadap waktu
  eksponensial  regresi log-linier (pertumbuhan persentase tetap)
  holt          pemulusan eksponensial Holt (level + tren), parameter dicari otomatis

Backtesting (rolling origin): untuk setiap tahun t mulai tahun ke-(min_latih+1),
metode dilatih HANYA dengan data sebelum t, lalu meramal t. Galatnya dicatat.
MAPE = rata-rata |aktual − ramalan| ÷ aktual. Metode dengan MAPE terkecil menang.

Rentang: titik rekomendasi = ramalan metode pemenang. Batas bawah dan atas diambil dari
sebaran galat historis metode itu (persentil 10 dan 90), jadi lebar rentangnya jujur
terhadap seberapa sering metode itu meleset di masa lalu.

Kebutuhan: Python 3.9+, tanpa pustaka tambahan. Minimal 5 tahun data per jenis pajak.

Contoh:
  python3 5_backtest_proyeksi.py --masukan contoh/contoh_transaksi.csv
  python3 5_backtest_proyeksi.py --masukan transaksi.csv --tahun-akhir 2025 --keluaran proyeksi.csv
"""

import argparse
import csv
import math
import sys
from collections import defaultdict
from datetime import datetime

FORMAT_TANGGAL = ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%Y/%m/%d")


def baca_tanggal(s):
    s = s.strip()
    for fmt in FORMAT_TANGGAL:
        try:
            return datetime.strptime(s[:10], fmt)
        except ValueError:
            pass
    raise ValueError(f"Format tanggal tidak dikenali: {s!r}")


def baca_nominal(s):
    s = str(s).strip().replace("Rp", "").replace(" ", "")
    # 1.250.000 atau 1.250.000,50 (format Indonesia) → 1250000.50
    if "," in s and s.rfind(",") > s.rfind("."):
        s = s.replace(".", "").replace(",", ".")
    elif s.count(".") > 1:
        s = s.replace(".", "")
    return float(s)


# ---------- tujuh metode: masing-masing menerima deret dan mengembalikan ramalan t+1 ----------

def naif(y):
    return y[-1]


def rerata_tumbuh(y):
    g = [(y[i] - y[i - 1]) / y[i - 1] for i in range(1, len(y)) if y[i - 1] > 0]
    return y[-1] * (1 + sum(g) / len(g)) if g else y[-1]


def cagr(y):
    if y[0] <= 0 or y[-1] <= 0:
        return y[-1]
    return y[-1] * (y[-1] / y[0]) ** (1 / (len(y) - 1))


def rerata_3(y):
    ekor = y[-3:]
    return sum(ekor) / len(ekor)


def _regresi(xs, ys):
    n = len(xs)
    mx, my = sum(xs) / n, sum(ys) / n
    sxx = sum((x - mx) ** 2 for x in xs)
    b = sum((x - mx) * (yv - my) for x, yv in zip(xs, ys)) / sxx if sxx else 0.0
    return my - b * mx, b


def linier(y):
    a, b = _regresi(list(range(len(y))), y)
    return a + b * len(y)


def eksponensial(y):
    if min(y) <= 0:
        return linier(y)
    a, b = _regresi(list(range(len(y))), [math.log(v) for v in y])
    return math.exp(a + b * len(y))


def holt(y):
    """Holt linear; alpha & beta dipilih dengan galat satu-langkah terkecil di dalam sampel."""
    terbaik = None
    grid = [i / 10 for i in range(1, 10)]
    for al in grid:
        for be in grid:
            level, tren = y[0], y[1] - y[0]
            sse = 0.0
            for t in range(1, len(y)):
                ramal = level + tren
                sse += (y[t] - ramal) ** 2
                level_baru = al * y[t] + (1 - al) * (level + tren)
                tren = be * (level_baru - level) + (1 - be) * tren
                level = level_baru
            if terbaik is None or sse < terbaik[0]:
                terbaik = (sse, level + tren)
    return terbaik[1]


METODE = {
    "naif": naif,
    "rerata_tumbuh": rerata_tumbuh,
    "cagr": cagr,
    "rerata_3": rerata_3,
    "linier": linier,
    "eksponensial": eksponensial,
    "holt": holt,
}


def persentil(xs, q):
    xs = sorted(xs)
    if len(xs) == 1:
        return xs[0]
    pos = (len(xs) - 1) * q
    bawah = int(math.floor(pos))
    atas = min(bawah + 1, len(xs) - 1)
    return xs[bawah] + (xs[atas] - xs[bawah]) * (pos - bawah)


def backtest(y, min_latih):
    hasil = {}
    for nama, f in METODE.items():
        ape, galat_bertanda = [], []
        for t in range(min_latih, len(y)):
            ramal = f(y[:t])
            if y[t] == 0 or ramal <= 0:
                continue
            ape.append(abs(y[t] - ramal) / y[t])
            galat_bertanda.append((y[t] - ramal) / ramal)
        if ape:
            hasil[nama] = {"mape": sum(ape) / len(ape), "galat": galat_bertanda}
    return hasil


def rp(x):
    return "Rp" + f"{x:,.0f}".replace(",", ".")


def main():
    p = argparse.ArgumentParser(description="Backtesting tujuh metode proyeksi PAD.")
    p.add_argument("--masukan", required=True)
    p.add_argument("--kolom-jenis", default="jenis_pajak")
    p.add_argument("--kolom-tanggal", default="tanggal_bayar")
    p.add_argument("--kolom-nominal", default="nominal")
    p.add_argument("--tahun-akhir", type=int,
                   help="tahun lengkap terakhir; tahun berjalan yang belum genap jangan diikutkan")
    p.add_argument("--min-latih", type=int, default=3, help="jumlah tahun minimum untuk melatih")
    p.add_argument("--keluaran", default="proyeksi.csv")
    a = p.parse_args()

    deret = defaultdict(lambda: defaultdict(float))
    with open(a.masukan, encoding="utf-8") as f:
        for i, r in enumerate(csv.DictReader(f), 2):
            try:
                th = baca_tanggal(r[a.kolom_tanggal]).year
                deret[r[a.kolom_jenis].strip()][th] += baca_nominal(r[a.kolom_nominal])
            except (KeyError, ValueError) as e:
                sys.exit(f"Baris {i}: {e}")

    keluaran = []
    for jenis in sorted(deret):
        per_tahun = deret[jenis]
        tahun = sorted(per_tahun)
        if a.tahun_akhir:
            tahun = [t for t in tahun if t <= a.tahun_akhir]
        y = [per_tahun[t] for t in tahun]
        print(f"\n=== {jenis} ({tahun[0]}–{tahun[-1]}, {len(y)} tahun) ===")
        if len(y) < a.min_latih + 2:
            print(f"  Dilewati: butuh minimal {a.min_latih + 2} tahun data.")
            continue

        hasil = backtest(y, a.min_latih)
        peringkat = sorted(hasil, key=lambda m: hasil[m]["mape"])
        for m in peringkat:
            tanda = "  ← pemenang" if m == peringkat[0] else ""
            print(f"  {m:<14} MAPE {100 * hasil[m]['mape']:6.2f}%{tanda}")

        pemenang = peringkat[0]
        titik = METODE[pemenang](y)
        g = hasil[pemenang]["galat"]
        bawah = titik * (1 + min(0.0, persentil(g, 0.10)))
        atas = titik * (1 + max(0.0, persentil(g, 0.90)))
        target = tahun[-1] + 1
        print(f"  Proyeksi {target}: bawah {rp(bawah)} | rekomendasi {rp(titik)} | atas {rp(atas)}")

        keluaran.append({
            "jenis_pajak": jenis,
            "tahun_target": target,
            "metode_terpilih": pemenang,
            "mape_persen": f"{100 * hasil[pemenang]['mape']:.2f}",
            "batas_bawah": round(bawah),
            "titik_rekomendasi": round(titik),
            "batas_atas": round(atas),
            "realisasi_tahun_terakhir": round(y[-1]),
            "jumlah_tahun_data": len(y),
        })

    if keluaran:
        with open(a.keluaran, "w", newline="", encoding="utf-8") as f:
            w = csv.DictWriter(f, fieldnames=list(keluaran[0].keys()))
            w.writeheader()
            w.writerows(keluaran)
        print(f"\nRingkasan disimpan ke {a.keluaran}")


if __name__ == "__main__":
    main()
