"""Web publik untuk mencocokkan penjualan tempo dan laporan marketplace.

Tidak ada paket Python eksternal atau database. File unggahan diproses di
memori dan tidak disimpan ke folder server.
"""

from __future__ import annotations

import csv
import io
import json
import os
import re
import threading
import time
import uuid
import webbrowser
import zipfile
from collections import defaultdict
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import unquote, urlparse
from xml.etree import ElementTree as ET


def read_int_env(name: str, default: int) -> int:
    try:
        return int(os.environ.get(name, str(default)))
    except ValueError:
        return default


# Host cloud umumnya mengirim PORT secara otomatis. Script Windows lokal
# menimpa HOST menjadi 127.0.0.1 agar tidak terbuka ke jaringan lokal.
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = read_int_env("PORT", 8765)
MAX_UPLOAD_BYTES = 100 * 1024 * 1024
RESULT_TTL_SECONDS = read_int_env("RESULT_TTL_SECONDS", 3600)
AUTO_OPEN_BROWSER = os.environ.get("OPEN_BROWSER", "").strip() == "1"
RUNS: dict[str, dict[str, Any]] = {}
RUNS_LOCK = threading.RLock()

DEFAULT_TEMPLATE_HEADERS = [
    "No. Faktur",
    "Status Pembayaran",
    "Metode Pembayaran",
    "Jumlah Dibayar",
    "Akun Pembayaran",
    "No. Akun Bank",
    "Status Pengiriman",
    "Rincian Pengiriman",
    "Nama Penerima",
]
DEFAULT_PAYMENT_ACCOUNT = "Muhammad Erliyanto"
ACCOUNT_PREFIXES = {
    "aurum_herb": "AH-",
    "toko_light": "TL-",
    "sayora.id": "SY-",
    "digilight.id": "DL-",
}

XLSX_NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"


class ValidationError(ValueError):
    """Pesan yang aman ditampilkan di aplikasi."""


def purge_expired_runs() -> None:
    """Buang hasil rekonsiliasi yang sudah melewati masa simpan sementara."""
    cutoff = time.time() - RESULT_TTL_SECONDS
    with RUNS_LOCK:
        for run_id in [key for key, value in RUNS.items() if value["created"] < cutoff]:
            RUNS.pop(run_id, None)


def start_expiry_sweeper() -> None:
    """Menjalankan pembersihan memori walau tidak ada permintaan baru."""
    def sweep() -> None:
        while True:
            time.sleep(max(1, min(60, RESULT_TTL_SECONDS)))
            purge_expired_runs()

    threading.Thread(target=sweep, name="result-expiry", daemon=True).start()


def cell_column(cell_ref: str) -> int:
    """Mengubah A1 menjadi indeks kolom (mulai nol)."""
    letters = "".join(char for char in cell_ref if char.isalpha())
    value = 0
    for char in letters.upper():
        value = value * 26 + ord(char) - ord("A") + 1
    return max(value - 1, 0)


def read_xlsx_sheets(content: bytes) -> list[list[list[str]]]:
    """Membaca nilai sel XLSX tanpa mengandalkan style workbook.

    Ekspor Shopee tertentu memiliki data style yang tidak selalu dapat dibaca
    oleh pembaca Excel umum. Pembaca ini sengaja hanya mengambil nilai dan
    mengabaikan style, sehingga file laporan tetap dapat diproses.
    """
    try:
        archive = zipfile.ZipFile(io.BytesIO(content))
    except zipfile.BadZipFile as exc:
        raise ValidationError("File Excel tidak valid atau rusak.") from exc

    with archive:
        names = set(archive.namelist())
        shared_strings: list[str] = []
        if "xl/sharedStrings.xml" in names:
            root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
            for item in root.findall(f".//{XLSX_NS}si"):
                shared_strings.append("".join(node.text or "" for node in item.findall(f".//{XLSX_NS}t")))

        sheet_names = [
            name
            for name in archive.namelist()
            if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)
        ]
        sheet_names.sort(key=lambda value: int(re.search(r"sheet(\d+)", value).group(1)))
        if not sheet_names:
            raise ValidationError("Tidak ada lembar data yang dapat dibaca di file Excel.")

        sheets: list[list[list[str]]] = []
        for sheet_name in sheet_names:
            root = ET.fromstring(archive.read(sheet_name))
            sheet_rows: list[list[str]] = []
            for row in root.findall(f".//{XLSX_NS}sheetData/{XLSX_NS}row"):
                values: dict[int, str] = {}
                for cell in row.findall(f"{XLSX_NS}c"):
                    index = cell_column(cell.get("r", "A1"))
                    value_node = cell.find(f"{XLSX_NS}v")
                    cell_type = cell.get("t", "")
                    if cell_type == "s" and value_node is not None:
                        try:
                            value = shared_strings[int(value_node.text or "0")]
                        except (IndexError, ValueError):
                            value = ""
                    elif cell_type == "inlineStr":
                        value = "".join(node.text or "" for node in cell.findall(f".//{XLSX_NS}is/{XLSX_NS}t"))
                    elif value_node is not None:
                        value = value_node.text or ""
                    else:
                        value = ""
                    values[index] = value.strip()

                if values:
                    row_values = [""] * (max(values) + 1)
                    for index, value in values.items():
                        row_values[index] = value
                    sheet_rows.append(row_values)
            if sheet_rows:
                sheets.append(sheet_rows)
        return sheets


def read_csv_rows(content: bytes) -> list[list[str]]:
    text = ""
    for encoding in ("utf-8-sig", "utf-16", "cp1252"):
        try:
            text = content.decode(encoding)
            break
        except UnicodeDecodeError:
            continue
    if not text:
        raise ValidationError("CSV tidak dapat dibaca. Simpan sebagai UTF-8 lalu coba lagi.")

    first_line = text.splitlines()[0] if text.splitlines() else ""
    delimiter = ";" if first_line.count(";") >= first_line.count(",") else ","
    return [[cell.strip() for cell in row] for row in csv.reader(io.StringIO(text), delimiter=delimiter)]


def read_sheets(filename: str, content: bytes) -> list[list[list[str]]]:
    extension = filename.lower().rsplit(".", 1)[-1] if "." in filename else ""
    if extension == "xlsx":
        return read_xlsx_sheets(content)
    if extension == "csv":
        return [read_csv_rows(content)]
    raise ValidationError("Gunakan file .xlsx atau .csv.")


def normalize_label(value: Any) -> str:
    return re.sub(r"\s+", " ", str(value or "").strip().casefold())


def normalize_key(value: Any) -> str:
    return re.sub(r"[^A-Z0-9]", "", str(value or "").upper())


def text_at(row: list[str], index: int | None) -> str:
    if index is None or index < 0 or index >= len(row):
        return ""
    return str(row[index]).strip()


def parse_amount(value: Any) -> float | None:
    """Membaca angka ekspor Indonesia seperti Rp 32.000 atau 32,000.00."""
    if value is None:
        return None
    raw = str(value).strip()
    if not raw or raw in {"-", "n/a", "N/A"}:
        return None
    raw = re.sub(r"[^0-9,.-]", "", raw).replace("-", "")
    if not raw:
        return None

    if "," in raw and "." in raw:
        if raw.rfind(",") > raw.rfind("."):
            raw = raw.replace(".", "").replace(",", ".")
        else:
            raw = raw.replace(",", "")
    elif "," in raw:
        left, right = raw.rsplit(",", 1)
        raw = f"{left}.{right}" if len(right) <= 2 else raw.replace(",", "")
    elif "." in raw:
        left, right = raw.rsplit(".", 1)
        raw = f"{left}.{right}" if len(right) <= 2 else raw.replace(".", "")
    try:
        return float(raw)
    except ValueError:
        return None


def display_amount(value: float | None) -> str:
    if value is None:
        return ""
    if abs(value - round(value)) < 0.005:
        return str(int(round(value)))
    return f"{value:.2f}"


def index_for(headers: list[str], aliases: list[str]) -> int | None:
    normalized = [normalize_label(header) for header in headers]
    for alias in aliases:
        alias_normalized = normalize_label(alias)
        if alias_normalized in normalized:
            return normalized.index(alias_normalized)
    for alias in aliases:
        alias_normalized = normalize_label(alias)
        for index, header in enumerate(normalized):
            if alias_normalized and alias_normalized in header:
                return index
    return None


SALES_FIELDS = {
    "invoice": ["no. faktur", "nomor faktur", "no faktur", "invoice number"],
    "recipient": ["nama penerima", "penerima"],
    "customer": ["nama pelanggan", "pelanggan", "customer"],
    "payment_status": ["status pembayaran", "payment status"],
    "total_sale": ["jumlah total", "harga penjualan", "total penjualan", "penjualan jatuh tempo"],
    "shipping_status": ["status pengiriman", "shipping status"],
    "shipping_detail": ["rincian pengiriman", "no. pesanan", "nomor pesanan", "order id", "id pesanan"],
}


def find_sales_rows(sheets: list[list[list[str]]]) -> list[dict[str, str]]:
    best: tuple[int, list[list[str]], int] | None = None
    for sheet in sheets:
        for row_index, row in enumerate(sheet[:30]):
            score = 0
            labels = [normalize_label(value) for value in row]
            if any("faktur" in label or "invoice" in label for label in labels):
                score += 3
            if any("rincian pengiriman" in label or "pesanan" in label or "order" in label for label in labels):
                score += 4
            if any("jumlah total" in label or "harga penjualan" in label for label in labels):
                score += 3
            if any("status pembayaran" in label for label in labels):
                score += 2
            if best is None or score > best[0]:
                best = (score, sheet, row_index)

    if best is None or best[0] < 7:
        raise ValidationError(
            "Kolom penjualan tidak ditemukan. File perlu memuat No. Faktur, Jumlah total, dan Rincian pengiriman."
        )

    _, sheet, header_row = best
    headers = sheet[header_row]
    mapping = {name: index_for(headers, aliases) for name, aliases in SALES_FIELDS.items()}
    missing = [
        label
        for label, field in (("No. Faktur", "invoice"), ("Jumlah total", "total_sale"), ("Rincian pengiriman", "shipping_detail"))
        if mapping[field] is None
    ]
    if missing:
        raise ValidationError("Kolom penjualan wajib belum ditemukan: " + ", ".join(missing) + ".")

    records: list[dict[str, str]] = []
    for row in sheet[header_row + 1 :]:
        record = {name: text_at(row, index) for name, index in mapping.items()}
        if not any(record.values()):
            continue
        if not record["invoice"] and not record["shipping_detail"]:
            continue
        if normalize_label(record["invoice"]).startswith("total") and not record["shipping_detail"]:
            # Abaikan baris total/ringkasan dari ekspor penjualan.
            continue
        payment_status = normalize_label(record["payment_status"])
        if mapping["payment_status"] is not None and payment_status and "tempo" not in payment_status:
            continue
        records.append(record)
    if not records:
        raise ValidationError("Tidak ada baris penjualan berstatus Tempo yang dapat diproses.")
    return records


def looks_like_balance_header(row: list[str]) -> bool:
    joined = " ".join(normalize_label(value) for value in row)
    return any(term in joined for term in ("no. pesanan", "nomor pesanan", "order id", "id pesanan")) and any(
        term in joined for term in ("jumlah", "nominal", "dana", "penghasilan", "amount")
    )


def find_balance_rows(
    sheets: list[list[list[str]]],
    account_prefix: str = "",
    source_name: str = "",
) -> list[dict[str, str]]:
    records: list[dict[str, str]] = []
    for sheet in sheets:
        # Laporan mentah Shopee menaruh tabel transaksi di bawah ringkasan,
        # sedangkan ekspor ringkas memulai data dari baris pertama.
        header_index = next((index for index, row in enumerate(sheet[:60]) if looks_like_balance_header(row)), None)
        if header_index is not None:
            headers = sheet[header_index]
            order_index = index_for(headers, ["no. pesanan", "nomor pesanan", "order id", "id pesanan"])
            amount_index = index_for(headers, ["jumlah diterima", "dana diterima", "jumlah", "nominal", "amount", "penghasilan"])
            direction_index = index_for(headers, ["jenis transaksi", "status transaksi", "tipe transaksi", "transaksi"])
            description_index = index_for(headers, ["deskripsi", "keterangan", "detail transaksi"])
            date_index = index_for(headers, ["tanggal transaksi", "tanggal", "date"])
            data_rows = sheet[header_index + 1 :]
        else:
            # Format ekspor my_balance Shopee: tanggal, judul, deskripsi, no pesanan,
            # jenis transaksi, dana masuk, status, saldo.
            order_index, amount_index = 3, 5
            direction_index, description_index = 4, 1
            date_index = 0
            data_rows = sheet

        for row in data_rows:
            order_number = text_at(row, order_index)
            amount = parse_amount(text_at(row, amount_index))
            direction = normalize_label(text_at(row, direction_index))
            description = normalize_label(text_at(row, description_index))
            if not order_number or amount is None or amount < 0:
                continue
            # Abaikan pengembalian/pencairan. Jika kolom jenis tidak tersedia,
            # baris tetap dipakai selama memiliki nomor pesanan dan nominal positif.
            if direction and not any(term in direction for term in ("masuk", "income", "penghasilan", "received")):
                continue
            if description and any(term in description for term in ("penarikan", "withdraw", "pengembalian", "refund")):
                continue
            records.append(
                {
                    "order": order_number,
                    "amount": display_amount(amount),
                    "raw_amount": str(amount),
                    "date": text_at(row, date_index),
                    "account_prefix": account_prefix,
                    "source_name": source_name,
                }
            )

    if not records:
        raise ValidationError(
            "Tidak ada transaksi dana masuk dengan nomor pesanan dan nominal yang terbaca pada file my_balance."
        )
    return records


def username_from_raw_shopee(sheets: list[list[list[str]]]) -> str:
    """Ambil Username (Penjual) dari B6 laporan mentah Shopee."""
    for sheet in sheets:
        if len(sheet) >= 6 and normalize_label(text_at(sheet[5], 0)) == "username (penjual)":
            username = text_at(sheet[5], 1)
            if username:
                return username
        for row in sheet[:15]:
            for index, value in enumerate(row):
                if normalize_label(value) == "username (penjual)":
                    username = text_at(row, index + 1)
                    if username:
                        return username
    raise ValidationError("Username Penjual tidak ditemukan di B6 pada laporan mentah Shopee.")


def tiktok_tokopedia_records_from_uploads(
    income_files: list[tuple[str, bytes]],
    raw_shopee_files: list[tuple[str, bytes]],
) -> list[dict[str, str]]:
    """Baca income TikTok/Tokopedia dan laporan mentah Shopee."""
    raw_records: list[dict[str, str]] = []

    def add_raw_shopee_report(filename: str, sheets: list[list[list[str]]]) -> None:
        username = username_from_raw_shopee(sheets)
        prefix = ACCOUNT_PREFIXES.get(normalize_label(username))
        if not prefix:
            raise ValidationError(
                f"Username Shopee '{username}' belum memiliki kode akun. Gunakan aurum_herb, toko_light, sayora.id, atau digilight.id."
            )
        raw_records.extend(find_balance_rows(sheets, account_prefix=prefix, source_name=filename))

    for filename, content in raw_shopee_files:
        add_raw_shopee_report(filename, read_sheets(filename, content))

    income_records: list[dict[str, str]] = []
    for filename, content in income_files:
        income_records.extend(find_tiktok_tokopedia_rows(read_sheets(filename, content), filename))

    combined = raw_records + income_records
    if not combined:
        raise ValidationError("Belum ada transaksi my_balance yang dapat diproses.")

    # Laporan ringkas dan laporan mentah pada periode sama dapat memuat baris
    # transaksi identik. Gabungkan menurut pesanan + nominal dan utamakan baris
    # yang mempunyai prefix akun dari B6 laporan mentah.
    merged: dict[tuple[str, str], dict[str, str]] = {}
    for record in combined:
        fingerprint = (normalize_key(record["order"]), record["raw_amount"])
        previous = merged.get(fingerprint)
        if previous is None or (record.get("account_prefix") and not previous.get("account_prefix")):
            merged[fingerprint] = record
    return list(merged.values())


def find_tiktok_tokopedia_rows(sheets: list[list[list[str]]], source_name: str) -> list[dict[str, str]]:
    """Baca laporan income TikTok Shop/Tokopedia: A = ID, F = penyelesaian."""
    records: list[dict[str, str]] = []
    header_found = False
    for sheet in sheets:
        header_index = next(
            (
                index
                for index, row in enumerate(sheet[:20])
                if normalize_label(text_at(row, 0)) == "id pesanan/penyesuaian"
                and normalize_label(text_at(row, 5)) == "jumlah penyelesaian pembayaran"
            ),
            None,
        )
        if header_index is None:
            continue
        header_found = True
        for row in sheet[header_index + 1 :]:
            # Berdasarkan format income TikTok/Tokopedia: kolom A dan F.
            order_number = text_at(row, 0)
            amount = parse_amount(text_at(row, 5))
            if not order_number or order_number in {"-", "/"} or amount is None or amount <= 0:
                continue
            records.append(
                {
                    "order": order_number,
                    "amount": display_amount(amount),
                    "raw_amount": str(amount),
                    "date": text_at(row, 3),
                    "account_prefix": "",
                    "source_name": source_name,
                }
            )
    if not header_found:
        raise ValidationError(
            "Format laporan income TikTok/Tokopedia tidak ditemukan. Pastikan kolom A adalah ID Pesanan/Penyesuaian dan kolom F adalah Jumlah Penyelesaian Pembayaran."
        )
    return records


def row_value_for_header(header: str, record: dict[str, Any]) -> str:
    normalized = normalize_label(header)
    values = {
        "no. faktur": record["invoice"],
        "status pembayaran": "Lunas",
        "metode pembayaran": "bank_transfer",
        # Nominal impor selalu mengikuti nilai penjualan tempo, bukan nilai
        # transaksi my_balance. Selisih positif diperlakukan sebagai bonus owner.
        "jumlah dibayar": display_amount(record["price"]),
        "akun pembayaran": DEFAULT_PAYMENT_ACCOUNT,
        "no. akun bank": record["account_number"],
        # Baris yang masuk CSV telah terbukti menerima dana dan siap diperbarui
        # sebagai selesai diterima pada sistem penjualan.
        "status pengiriman": "Diterima",
        "rincian pengiriman": record["shipping_detail"],
        # Jangan mengisi Nama Penerima dengan nama kanal/marketplace. Field ini
        # hanya diisi jika file sumber memang memiliki kolom Nama Penerima.
        "nama penerima": record["recipient"],
    }
    return values.get(normalized, "")


def reconcile(
    sales_sheets: list[list[list[str]]],
    balance: list[dict[str, str]],
    template_headers: list[str],
    include_shortfall: bool,
) -> dict[str, Any]:
    sales = find_sales_rows(sales_sheets)

    payments: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for item in balance:
        key = normalize_key(item["order"])
        if key:
            payments[key].append(
                {
                    "amount": float(item["raw_amount"]),
                    "order": item["order"],
                    "account_prefix": item.get("account_prefix", ""),
                }
            )

    audit_rows: list[dict[str, Any]] = []
    import_rows: list[dict[str, str]] = []
    summary = {
        "tempoCount": len(sales),
        "paymentRows": len(balance),
        "matched": 0,
        "verified": 0,
        "shortfall": 0,
        "pending": 0,
        "surplus": 0,
        "totalSales": 0.0,
        "totalReceived": 0.0,
        "totalShortfall": 0.0,
        "totalOwnerBonus": 0.0,
    }

    for sale in sales:
        price = parse_amount(sale["total_sale"])
        key = normalize_key(sale["shipping_detail"])
        payment_list = payments.get(key, []) if key else []
        received = sum(item["amount"] for item in payment_list)
        account_number = (
            f"{payment_list[0]['account_prefix']}{payment_list[0]['order']}" if payment_list else ""
        )
        difference = (received - price) if price is not None else None
        owner_bonus = max(difference or 0, 0)

        if price is None:
            status = "Harga tidak terbaca"
            detail = "Jumlah total pada penjualan tidak dapat dibaca."
        elif not key:
            status = "Nomor pesanan kosong"
            detail = "Rincian pengiriman belum terisi."
            summary["totalSales"] += price
        elif not payment_list:
            status = "Belum diterima"
            detail = "Tidak ada transaksi dana masuk dengan nomor pesanan ini."
            summary["pending"] += 1
            summary["totalSales"] += price
        else:
            summary["matched"] += 1
            summary["totalSales"] += price
            summary["totalReceived"] += received
            if difference is not None and difference < -0.005:
                status = "Dana kurang"
                detail = "Dana masuk lebih kecil daripada harga penjualan."
                summary["shortfall"] += 1
                summary["totalShortfall"] += abs(difference)
            elif difference is not None and difference > 0.005:
                status = "Dana lebih"
                detail = "Dana masuk lebih besar daripada harga penjualan. Selisih dicatat sebagai bonus owner."
                summary["surplus"] += 1
                summary["totalOwnerBonus"] += owner_bonus
            else:
                status = "Sesuai"
                detail = "Dana masuk sama dengan harga penjualan."
                summary["verified"] += 1

        audit = {
            "invoice": sale["invoice"],
            "shipping_detail": sale["shipping_detail"],
            "price": price,
            "received": received if payment_list else None,
            "difference": difference if payment_list and price is not None else None,
            "owner_bonus": owner_bonus if payment_list and price is not None else None,
            "account_number": account_number,
            "status": status,
            "payment_count": len(payment_list),
            "shipping_status": sale["shipping_status"],
            "customer": sale["recipient"] or sale["customer"],
            "detail": detail,
        }
        audit_rows.append(audit)

        is_safe_to_import = status in {"Sesuai", "Dana lebih"} or (include_shortfall and status == "Dana kurang")
        if is_safe_to_import:
            import_rows.append({header: row_value_for_header(header, {**sale, **audit}) for header in template_headers})

    # Transaksi yang butuh evaluasi owner selalu di paling atas. Sesudah itu,
    # seluruh transaksi yang berhasil dicocokkan tampil sebelum yang belum
    # menerima dana supaya tidak perlu menggulir jauh ke bawah.
    status_order = {
        "Dana kurang": 0,
        "Dana lebih": 1,
        "Sesuai": 2,
        "Harga tidak terbaca": 3,
        "Nomor pesanan kosong": 4,
        "Belum diterima": 5,
    }
    audit_rows.sort(key=lambda item: (status_order.get(item["status"], 6), item["invoice"]))
    return {
        "summary": summary,
        "auditRows": audit_rows,
        "importRows": import_rows,
        "templateHeaders": template_headers,
        "includeShortfall": include_shortfall,
    }


def csv_bytes(headers: list[str], rows: list[dict[str, Any]]) -> bytes:
    stream = io.StringIO(newline="")
    writer = csv.writer(stream, delimiter=";", lineterminator="\r\n")
    writer.writerow(headers)
    for row in rows:
        writer.writerow([row.get(header, "") for header in headers])
    return b"\xef\xbb\xbf" + stream.getvalue().encode("utf-8")


def import_csv(run: dict[str, Any]) -> bytes:
    return csv_bytes(run["templateHeaders"], run["importRows"])


def audit_csv(run: dict[str, Any]) -> bytes:
    headers = [
        "No. Faktur",
        "Rincian Pengiriman / No. Pesanan",
        "No. Akun / No. Pesanan",
        "Harga Penjualan",
        "Dana Diterima",
        "Selisih (Diterima - Harga)",
        "Bonus Owner",
        "Status Rekonsiliasi",
        "Jumlah Transaksi Sumber",
        "Status Pengiriman",
        "Nama Penerima",
        "Keterangan",
    ]
    rows = []
    for audit in run["auditRows"]:
        rows.append(
            {
                headers[0]: audit["invoice"],
                headers[1]: audit["shipping_detail"],
                headers[2]: audit["account_number"],
                headers[3]: display_amount(audit["price"]),
                headers[4]: display_amount(audit["received"]),
                headers[5]: display_amount(audit["difference"]),
                headers[6]: display_amount(audit["owner_bonus"]),
                headers[7]: audit["status"],
                headers[8]: audit["payment_count"],
                headers[9]: audit["shipping_status"],
                headers[10]: audit["customer"],
                headers[11]: audit["detail"],
            }
        )
    return csv_bytes(headers, rows)


def parse_multipart(handler: BaseHTTPRequestHandler) -> dict[str, list[tuple[str, bytes]]]:
    content_type = handler.headers.get("Content-Type", "")
    length_header = handler.headers.get("Content-Length", "0")
    try:
        content_length = int(length_header)
    except ValueError as exc:
        raise ValidationError("Ukuran unggahan tidak valid.") from exc
    if content_length <= 0:
        raise ValidationError("Belum ada file yang diunggah.")
    if content_length > MAX_UPLOAD_BYTES:
        raise ValidationError("Ukuran total file melebihi 100 MB.")
    if "multipart/form-data" not in content_type or "boundary=" not in content_type:
        raise ValidationError("Format unggahan tidak dikenali.")

    boundary = content_type.split("boundary=", 1)[1].strip().strip('"').encode("utf-8")
    body = handler.rfile.read(content_length)
    parts: dict[str, list[tuple[str, bytes]]] = defaultdict(list)
    for part in body.split(b"--" + boundary):
        if not part or part in (b"--\r\n", b"--"):
            continue
        part = part.lstrip(b"\r\n")
        headers_raw, separator, payload = part.partition(b"\r\n\r\n")
        if not separator:
            continue
        # Setiap bagian multipart berakhir dengan satu CRLF. Hapus hanya
        # pemisahnya agar byte akhir file Excel tidak ikut terpotong.
        if payload.endswith(b"\r\n"):
            payload = payload[:-2]
        disposition = headers_raw.decode("utf-8", "replace")
        name_match = re.search(r'name="([^"]+)"', disposition)
        filename_match = re.search(r'filename="([^"]*)"', disposition)
        if not name_match:
            continue
        name = name_match.group(1)
        filename = filename_match.group(1) if filename_match else ""
        parts[name].append((filename, payload))
    return dict(parts)


def safe_preview(audit: dict[str, Any]) -> dict[str, Any]:
    return {
        "invoice": audit["invoice"],
        "shippingDetail": audit["shipping_detail"],
        "price": audit["price"],
        "received": audit["received"],
        "difference": audit["difference"],
        "ownerBonus": audit["owner_bonus"],
        "accountNumber": audit["account_number"],
        "status": audit["status"],
        "paymentCount": audit["payment_count"],
        "detail": audit["detail"],
    }


class AppHandler(BaseHTTPRequestHandler):
    server_version = "RekonsiliasiTempo/1.0"

    def log_message(self, _format: str, *_args: Any) -> None:
        # Hindari mencetak metadata file pengguna ke terminal.
        return

    def version_string(self) -> str:
        return "RekonsiliasiTempo"

    def send_private_headers(self) -> None:
        """Cegah hasil dan isi unggahan tersisa di cache browser/proxy."""
        self.send_header("Cache-Control", "no-store, max-age=0")
        self.send_header("Pragma", "no-cache")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("X-Frame-Options", "DENY")
        self.send_header("Referrer-Policy", "no-referrer")
        self.send_header("Cross-Origin-Opener-Policy", "same-origin")
        self.send_header(
            "Content-Security-Policy",
            "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; "
            "img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
        )

    def send_json(self, status: int, data: dict[str, Any]) -> None:
        content = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.send_private_headers()
        self.end_headers()
        self.wfile.write(content)

    def send_file(self, content: bytes, filename: str) -> None:
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", "text/csv; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
        self.send_private_headers()
        self.end_headers()
        self.wfile.write(content)

    def do_GET(self) -> None:  # noqa: N802
        purge_expired_runs()
        path = urlparse(self.path).path
        if path == "/":
            content = PAGE.encode("utf-8")
            self.send_response(HTTPStatus.OK)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(content)))
            self.send_private_headers()
            self.end_headers()
            self.wfile.write(content)
            return

        export_match = re.fullmatch(r"/api/export/([a-f0-9-]+)/(template|audit)", path)
        if export_match:
            with RUNS_LOCK:
                run = RUNS.get(export_match.group(1))
            if not run:
                self.send_json(HTTPStatus.NOT_FOUND, {"error": "Hasil sudah tidak tersedia. Proses ulang file Anda."})
                return
            if export_match.group(2) == "template":
                self.send_file(import_csv(run), "template_import_status.csv")
            else:
                self.send_file(audit_csv(run), "laporan_rekonsiliasi_penjualan_tempo.csv")
            return

        self.send_json(HTTPStatus.NOT_FOUND, {"error": "Halaman tidak ditemukan."})

    def do_POST(self) -> None:  # noqa: N802
        if urlparse(self.path).path != "/api/reconcile":
            self.send_json(HTTPStatus.NOT_FOUND, {"error": "Tujuan permintaan tidak ditemukan."})
            return
        try:
            purge_expired_runs()
            parts = parse_multipart(self)
            if "sales" not in parts or ("income" not in parts and "rawBalance" not in parts):
                raise ValidationError("Pilih file Penjualan Tempo dan setidaknya satu laporan TikTok/Tokopedia atau Shopee.")
            sales_name, sales_content = parts["sales"][0]
            income_files = parts.get("income", [])
            raw_shopee_files = parts.get("rawBalance", [])
            include_shortfall = parts.get("includeShortfall", [("", b"")])[0][1].decode("utf-8", "ignore") == "true"

            result = reconcile(
                read_sheets(sales_name, sales_content),
                tiktok_tokopedia_records_from_uploads(income_files, raw_shopee_files),
                DEFAULT_TEMPLATE_HEADERS.copy(),
                include_shortfall,
            )
            run_id = str(uuid.uuid4())
            with RUNS_LOCK:
                RUNS[run_id] = {**result, "created": time.time()}

            self.send_json(
                HTTPStatus.OK,
                {
                    "runId": run_id,
                    "summary": result["summary"],
                    "importCount": len(result["importRows"]),
                    "includeShortfall": include_shortfall,
                    "rows": [safe_preview(row) for row in result["auditRows"][:300]],
                    "truncated": len(result["auditRows"]) > 300,
                },
            )
        except ValidationError as exc:
            self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
        except Exception:
            self.send_json(
                HTTPStatus.INTERNAL_SERVER_ERROR,
                {"error": "Aplikasi tidak dapat memproses file. Pastikan file tidak terkunci dan formatnya benar."},
            )


PAGE = r'''<!doctype html>
<html lang="id">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Rekonsiliasi Penjualan Tempo</title>
  <style>
    :root { --ink:#182432; --muted:#637083; --paper:#f6f7f8; --card:#fff; --line:#dde2e7; --blue:#275d9d; --blue-dark:#193f6a; --green:#167053; --green-soft:#e8f5ef; --amber:#a96300; --amber-soft:#fff3df; --red:#b23939; --red-soft:#fceaea; }
    * { box-sizing:border-box; }
    body { margin:0; color:var(--ink); background:var(--paper); font-family:Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
    .top { background:linear-gradient(120deg,#17385d,#286e8e); color:white; padding:38px 24px 35px; }
    .wrap { max-width:1160px; margin:0 auto; }
    .eyebrow { margin:0 0 8px; font-size:12px; font-weight:750; letter-spacing:.1em; text-transform:uppercase; opacity:.78; }
    h1 { margin:0; font-size:clamp(28px,4vw,42px); letter-spacing:-.035em; }
    .sub { max-width:700px; margin:10px 0 0; color:#e4f0f6; line-height:1.55; }
    main { padding:28px 24px 46px; }
    .panel { background:var(--card); border:1px solid var(--line); border-radius:16px; box-shadow:0 6px 20px rgba(32,53,70,.06); padding:24px; }
    h2 { font-size:18px; margin:0 0 8px; letter-spacing:-.015em; }
    .help { color:var(--muted); margin:0; font-size:14px; line-height:1.5; }
    .files { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:14px; margin:20px 0 18px; }
    .file { min-height:143px; border:1.5px dashed #aebbc9; border-radius:12px; padding:17px; background:#fbfcfd; display:flex; flex-direction:column; gap:8px; }
    .file strong { font-size:14px; }
    .file span { color:var(--muted); font-size:12px; line-height:1.4; min-height:34px; }
    input[type=file] { max-width:100%; font-size:12px; color:#405061; }
    .row { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
    .check { display:flex; align-items:flex-start; gap:9px; color:#45566a; font-size:13px; line-height:1.4; }
    .check input { width:16px; height:16px; accent-color:var(--blue); margin:1px 0 0; }
    button, .download { appearance:none; border:0; border-radius:9px; padding:12px 17px; font:inherit; font-weight:720; cursor:pointer; text-decoration:none; text-align:center; }
    button { background:var(--blue); color:white; box-shadow:0 3px 8px rgba(39,93,157,.2); }
    button:hover { background:var(--blue-dark); }
    button:disabled { opacity:.65; cursor:wait; }
    #message { margin:16px 0 0; font-size:14px; }
    .error { color:var(--red); } .ok { color:var(--green); }
    #results { display:none; margin-top:24px; }
    .cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); gap:12px; margin:18px 0; }
    .card { border:1px solid var(--line); border-radius:12px; background:white; padding:14px; }
    .card .label { color:var(--muted); font-size:12px; line-height:1.25; min-height:31px; }
    .card .value { margin-top:7px; font-size:23px; font-weight:760; letter-spacing:-.035em; }
    .card.warning .value { color:var(--red); } .card.good .value { color:var(--green); }
    .downloads { display:flex; gap:10px; flex-wrap:wrap; margin:16px 0 22px; }
    .download { color:#fff; background:var(--green); } .download.secondary { background:#edf2f7; color:#27445f; }
    .download:hover { filter:brightness(.95); }
    .table-box { border:1px solid var(--line); border-radius:12px; overflow:auto; background:white; }
    table { border-collapse:collapse; width:100%; min-width:1080px; font-size:13px; }
    th { background:#eff4f8; color:#33485d; font-size:11px; letter-spacing:.04em; text-transform:uppercase; text-align:left; white-space:nowrap; }
    th, td { padding:11px 12px; border-bottom:1px solid #edf0f3; vertical-align:top; }
    tr:last-child td { border-bottom:0; }
    tr.row-dana-kurang td { background:#fff0f0; color:#822a2a; font-weight:650; }
    tr.row-dana-kurang td:first-child { border-left:5px solid var(--red); }
    .number { text-align:right; white-space:nowrap; font-variant-numeric:tabular-nums; }
    .status { display:inline-block; padding:4px 8px; border-radius:999px; font-size:11px; font-weight:720; white-space:nowrap; }
    .status-sesuai,.status-dana-lebih { background:var(--green-soft); color:var(--green); }
    .status-dana-kurang,.status-harga-tidak-terbaca,.status-nomor-pesanan-kosong { background:var(--red-soft); color:var(--red); }
    .status-belum-diterima { background:var(--amber-soft); color:var(--amber); }
    .note { color:var(--muted); font-size:12px; line-height:1.35; max-width:230px; }
    footer { color:var(--muted); font-size:12px; margin-top:17px; }
    @media (max-width:800px) { .files { grid-template-columns:1fr; } .cards { grid-template-columns:repeat(2,minmax(0,1fr)); } .top { padding-top:28px; } }
  </style>
</head>
<body>
  <header class="top"><div class="wrap">
    <p class="eyebrow">Pemeriksaan pembayaran</p>
    <h1>Rekonsiliasi Penjualan Tempo</h1>
    <p class="sub">Cocokkan penjualan tempo dengan transaksi my_balance menggunakan <b>Rincian pengiriman ↔ No. pesanan</b>. Data diproses hanya di komputer ini.</p>
  </div></header>
  <main><div class="wrap">
    <section class="panel">
      <h2>Unggah file</h2>
      <p class="help">Format impor sudah tersimpan di aplikasi. Gunakan laporan income TikTok Shop/Tokopedia dan laporan mentah Shopee sesuai kebutuhan.</p>
      <form id="form">
        <div class="files">
          <label class="file"><strong>1. Penjualan Tempo</strong><span>File Excel dengan No. Faktur, Jumlah total, dan Rincian pengiriman.</span><input id="sales" name="sales" type="file" accept=".xlsx,.csv" required></label>
          <label class="file"><strong>2. TikTok Shop / Tokopedia income</strong><span>Boleh pilih banyak laporan income. ID Pesanan/Penyesuaian dibaca dari kolom A dan Jumlah Penyelesaian Pembayaran dari kolom F.</span><input id="income" name="income" type="file" accept=".xlsx,.csv" multiple></label>
          <label class="file"><strong>3. Laporan mentah Shopee</strong><span>Opsional, boleh banyak. B6 diambil sebagai Username Penjual untuk kode AH-, TL-, SY-, atau DL-.</span><input id="rawBalance" name="rawBalance" type="file" accept=".xlsx,.csv" multiple></label>
        </div>
        <div class="row">
          <label class="check"><input id="includeShortfall" type="checkbox"><span>Sertakan transaksi <b>dana kurang</b> pada template impor. Nominal impor selalu mengikuti penjualan tempo; dana lebih dicatat sebagai bonus owner.</span></label>
          <button id="submit" type="submit">Proses dan cek data</button>
        </div>
      </form>
      <p id="message" aria-live="polite"></p>
    </section>

    <section id="results" class="panel">
      <h2>Hasil rekonsiliasi</h2>
      <p id="resultHelp" class="help"></p>
      <div class="cards">
        <div class="card"><div class="label">Penjualan tempo</div><div id="tempoCount" class="value">0</div></div>
        <div class="card"><div class="label">Nomor pesanan cocok</div><div id="matched" class="value">0</div></div>
        <div class="card good"><div class="label">Dana sesuai</div><div id="verified" class="value">0</div></div>
        <div class="card warning"><div class="label">Dana kurang</div><div id="shortfall" class="value">0</div></div>
        <div class="card good"><div class="label">Bonus owner</div><div id="ownerBonus" class="value">Rp0</div></div>
        <div class="card"><div class="label">Belum diterima</div><div id="pending" class="value">0</div></div>
      </div>
      <div class="downloads">
        <a id="downloadTemplate" class="download" href="#">Unduh template_import_status.csv</a>
        <a id="downloadAudit" class="download secondary" href="#">Unduh laporan rekonsiliasi.csv</a>
      </div>
      <div class="table-box"><table>
        <thead><tr><th>No. Faktur</th><th>Rincian pengiriman</th><th>No. akun / no. pesanan</th><th class="number">Harga jual</th><th class="number">Dana diterima</th><th class="number">Bonus owner</th><th class="number">Selisih</th><th>Status</th><th>Keterangan</th></tr></thead>
        <tbody id="auditBody"></tbody>
      </table></div>
      <footer id="footer"></footer>
    </section>
  </div></main>
  <script>
    const rupiah = new Intl.NumberFormat('id-ID',{style:'currency',currency:'IDR',maximumFractionDigits:0});
    const number = new Intl.NumberFormat('id-ID');
    const el = id => document.getElementById(id);
    const esc = value => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
    const money = value => value === null || value === undefined ? '—' : rupiah.format(value);
    const statusClass = value => 'status-' + String(value).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');

    el('form').addEventListener('submit', async event => {
      event.preventDefault();
      const submit = el('submit'); const message = el('message');
      const incomeFiles = [...el('income').files];
      const rawBalanceFiles = [...el('rawBalance').files];
      if (!incomeFiles.length && !rawBalanceFiles.length) { message.className = 'error'; message.textContent = 'Pilih setidaknya satu laporan income TikTok/Tokopedia atau laporan mentah Shopee.'; return; }
      const payload = new FormData();
      payload.append('sales', el('sales').files[0]);
      incomeFiles.forEach(file => payload.append('income', file));
      rawBalanceFiles.forEach(file => payload.append('rawBalance', file));
      payload.append('includeShortfall', el('includeShortfall').checked ? 'true' : 'false');
      submit.disabled = true; submit.textContent = 'Memproses…'; message.className = ''; message.textContent = 'Mencocokkan data dan menghitung selisih pembayaran…';
      try {
        const response = await fetch('/api/reconcile',{method:'POST',body:payload});
        const data = await response.json();
        if (!response.ok) throw new Error(data.error || 'Data tidak dapat diproses.');
        render(data); message.className = 'ok'; message.textContent = 'Selesai. Periksa transaksi dana kurang sebelum mengimpor status pembayaran.';
      } catch (error) { message.className = 'error'; message.textContent = error.message; }
      finally { submit.disabled = false; submit.textContent = 'Proses dan cek data'; }
    });

    function render(data) {
      const s = data.summary;
      el('results').style.display = 'block';
      el('tempoCount').textContent = number.format(s.tempoCount);
      el('matched').textContent = number.format(s.matched);
      el('verified').textContent = number.format(s.verified);
      el('shortfall').textContent = number.format(s.shortfall);
      el('ownerBonus').textContent = money(s.totalOwnerBonus);
      el('pending').textContent = number.format(s.pending);
      el('resultHelp').textContent = `${number.format(data.importCount)} baris siap diimpor sebagai Lunas. Jumlah Dibayar mengikuti nominal tempo, Akun Pembayaran diisi Muhammad Erliyanto, dan total bonus owner: ${money(s.totalOwnerBonus)}.`;
      el('downloadTemplate').href = `/api/export/${data.runId}/template`;
      el('downloadAudit').href = `/api/export/${data.runId}/audit`;
      el('auditBody').innerHTML = data.rows.map(row => `<tr class="${row.status === 'Dana kurang' ? 'row-dana-kurang' : ''}">
        <td>${esc(row.invoice)}</td><td>${esc(row.shippingDetail)}</td><td>${esc(row.accountNumber)}</td>
        <td class="number">${money(row.price)}</td><td class="number">${money(row.received)}</td>
        <td class="number">${money(row.ownerBonus)}</td><td class="number">${money(row.difference)}</td><td><span class="status ${statusClass(row.status)}">${esc(row.status)}</span></td>
        <td class="note">${esc(row.detail)}${row.paymentCount > 1 ? ` (${number.format(row.paymentCount)} transaksi)` : ''}</td>
      </tr>`).join('');
      el('footer').textContent = data.truncated ? 'Tabel menampilkan 300 baris pertama. Gunakan laporan rekonsiliasi untuk seluruh data.' : 'Semua baris ditampilkan. Dana kurang tidak otomatis dianggap Lunas.';
      el('results').scrollIntoView({behavior:'smooth',block:'start'});
    }
  </script>
</body>
</html>'''


if __name__ == "__main__":
    local_url = f"http://127.0.0.1:{PORT}"
    public_url = os.environ.get("PUBLIC_URL", "").strip()
    print(f"Aplikasi berjalan di {public_url or local_url}")
    print("Tekan Ctrl+C untuk menghentikan aplikasi.")
    if AUTO_OPEN_BROWSER:
        threading.Timer(0.7, lambda: webbrowser.open(local_url)).start()
    start_expiry_sweeper()
    ThreadingHTTPServer((HOST, PORT), AppHandler).serve_forever()
