"""Company-portal intercompany transfer summary / detail / export (HEL-175/176).

Confirmed totals reuse the authoritative ``eligible_intercompany_events``
view (intercompany + paired or locked). Pending counts/amounts are listed
separately and never enter outflow, inflow or net. All money math uses
``Decimal`` on stored TEXT amounts — never float or SQLite SUM.

Detail listing (HEL-176) applies counterparty / date / direction / state
filters inside SQL before LIMIT, and uses keyset pagination on
(effective_at, event_id) descending. Export only ships confirmed rows.
"""

from __future__ import annotations

import base64
import csv
import io
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
import re
import sqlite3

from . import calculation, matching, settings

_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_ZERO = Decimal("0.00")
_DEFAULT_PAGE = 50
_MAX_PAGE = 200
_MAX_EXPORT_ROWS = 20000


class TransferSummaryInputError(ValueError):
    """Invalid query parameters (mapped to HTTP 400)."""


def today_shanghai() -> str:
    return datetime.now(timezone(timedelta(hours=8))).date().isoformat()


def _validate_date(value: object, label: str) -> str:
    text = str(value or "").strip()
    if not text or not _DATE_RE.fullmatch(text):
        raise TransferSummaryInputError(f"{label}必须是 YYYY-MM-DD 格式。")
    try:
        datetime.strptime(text, "%Y-%m-%d")
    except ValueError as exc:
        raise TransferSummaryInputError(f"{label}不是有效日期。") from exc
    return text


def _money(value: Decimal) -> str:
    return format(value.quantize(Decimal("0.01")), "f")


def _as_decimal(raw: object) -> Decimal:
    try:
        return Decimal(str(raw))
    except (InvalidOperation, TypeError) as exc:
        raise TransferSummaryInputError("金额数据无效，无法汇总。") from exc


def _company_row(connection: sqlite3.Connection, company_id: int) -> dict[str, object]:
    row = connection.execute(
        "SELECT id, name FROM companies WHERE id = ?", (company_id,)
    ).fetchone()
    if row is None:
        raise TransferSummaryInputError("本公司不存在。")
    return {"id": int(row["id"]), "name": row["name"]}


def _window_bounds(
    connection: sqlite3.Connection, as_of: str | None
) -> tuple[str, str]:
    end = _validate_date(as_of, "as_of") if as_of else today_shanghai()
    calc_start = calculation.get_calculation_start_date(connection)
    start = calc_start or settings.get_settings(connection).get("start_date") or "2026-01-01"
    start = _validate_date(start, "start_date")
    if start > end:
        # Opening / start-date plumbing may lag; clamp rather than 500.
        start = end
    return start, end


def _load_confirmed(
    connection: sqlite3.Connection, company_id: int, start: str, end: str
) -> list[sqlite3.Row]:
    return connection.execute(
        """
        SELECT e.event_id, e.decision_id, e.effective_at, e.amount, e.currency,
               e.payer_company_id, e.payee_company_id, e.pairing,
               cpayer.name AS payer_company_name,
               cpayee.name AS payee_company_name
        FROM eligible_intercompany_events e
        JOIN companies cpayer ON cpayer.id = e.payer_company_id
        JOIN companies cpayee ON cpayee.id = e.payee_company_id
        WHERE (e.payer_company_id = ? OR e.payee_company_id = ?)
          AND e.effective_at >= ?
          AND e.effective_at <= ?
        ORDER BY e.event_id
        """,
        (company_id, company_id, start, end + "T23:59:59"),
    ).fetchall()


def _load_pending(
    connection: sqlite3.Connection, company_id: int, start: str, end: str
) -> list[sqlite3.Row]:
    """Pending = not eligible confirmed, still company-visible for tip only.

    Includes unresolved / needs_review / internal_single, plus any
    intercompany decision that is neither paired nor locked.
    """
    return connection.execute(
        """
        SELECT c.event_id, d.id AS decision_id, d.effective_at, d.amount,
               d.currency, d.classification, d.pairing, d.locked,
               payer.company_id AS payer_company_id,
               payee.company_id AS payee_company_id,
               cpayer.name AS payer_company_name,
               cpayee.name AS payee_company_name
        FROM current_transfer_decisions c
        JOIN transfer_match_decisions d ON d.id = c.decision_id
        JOIN canonical_transfer_events e ON e.id = c.event_id
        LEFT JOIN transfer_decision_participants payer
             ON payer.decision_id = d.id AND payer.role = 'payer'
        LEFT JOIN transfer_decision_participants payee
             ON payee.decision_id = d.id AND payee.role = 'payee'
        LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
        LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
        WHERE e.lifecycle = 'active'
          AND (payer.company_id = ? OR payee.company_id = ?)
          AND d.effective_at >= ?
          AND d.effective_at <= ?
          AND (
                d.classification IN ('unresolved', 'needs_review', 'internal_single')
             OR (
                    d.classification = 'intercompany'
                AND d.pairing != 'paired'
                AND d.locked = 0
             )
          )
        ORDER BY c.event_id
        """,
        (company_id, company_id, start, end + "T23:59:59"),
    ).fetchall()


def _counterparty_of(row: sqlite3.Row, company_id: int) -> tuple[int | None, str | None]:
    payer = row["payer_company_id"]
    payee = row["payee_company_id"]
    if payer is not None and int(payer) == int(company_id):
        if payee is None:
            return None, None
        return int(payee), row["payee_company_name"]
    if payee is not None and int(payee) == int(company_id):
        if payer is None:
            return None, None
        return int(payer), row["payer_company_name"]
    return None, None


def _is_outflow(row: sqlite3.Row, company_id: int) -> bool:
    return row["payer_company_id"] is not None and int(row["payer_company_id"]) == int(
        company_id
    )


def company_intercompany_summary(
    connection: sqlite3.Connection,
    *,
    company_id: int,
    as_of: str | None = None,
) -> dict[str, object]:
    """Build the HEL-169 summary payload for one company session."""
    own = _company_row(connection, company_id)
    start, end = _window_bounds(connection, as_of)

    confirmed_rows = _load_confirmed(connection, company_id, start, end)
    pending_rows = _load_pending(connection, company_id, start, end)

    seen_confirmed: set[int] = set()
    outflow = _ZERO
    inflow = _ZERO
    outflow_count = 0
    inflow_count = 0

    # counterparty_id -> bucket
    buckets: dict[int, dict[str, object]] = {}

    def bucket_for(cp_id: int, cp_name: str | None) -> dict[str, object]:
        bucket = buckets.get(cp_id)
        if bucket is None:
            bucket = {
                "company_id": cp_id,
                "company_name": cp_name or "",
                "confirmed_outflow": _ZERO,
                "confirmed_inflow": _ZERO,
                "pending_count": 0,
                "last_effective_at": None,
            }
            buckets[cp_id] = bucket
        elif cp_name and not bucket["company_name"]:
            bucket["company_name"] = cp_name
        return bucket

    def touch_last(bucket: dict[str, object], effective_at: str | None) -> None:
        if not effective_at:
            return
        previous = bucket["last_effective_at"]
        if previous is None or str(effective_at) > str(previous):
            bucket["last_effective_at"] = effective_at

    for row in confirmed_rows:
        event_id = int(row["event_id"])
        if event_id in seen_confirmed:
            continue
        seen_confirmed.add(event_id)
        amount = _as_decimal(row["amount"])
        cp_id, cp_name = _counterparty_of(row, company_id)
        if _is_outflow(row, company_id):
            outflow += amount
            outflow_count += 1
            if cp_id is not None:
                bucket = bucket_for(cp_id, cp_name)
                bucket["confirmed_outflow"] += amount
                touch_last(bucket, row["effective_at"])
        else:
            inflow += amount
            inflow_count += 1
            if cp_id is not None:
                bucket = bucket_for(cp_id, cp_name)
                bucket["confirmed_inflow"] += amount
                touch_last(bucket, row["effective_at"])

    pending_total = _ZERO
    seen_pending: set[int] = set()
    for row in pending_rows:
        event_id = int(row["event_id"])
        if event_id in seen_pending or event_id in seen_confirmed:
            continue
        seen_pending.add(event_id)
        amount = _as_decimal(row["amount"])
        pending_total += amount
        cp_id, cp_name = _counterparty_of(row, company_id)
        if cp_id is not None:
            bucket = bucket_for(cp_id, cp_name)
            bucket["pending_count"] = int(bucket["pending_count"]) + 1
            touch_last(bucket, row["effective_at"])

    net = outflow - inflow
    if net > 0:
        net_direction: str | None = "receivable"
    elif net < 0:
        net_direction = "payable"
    else:
        net_direction = None

    counterparties = []
    for cp_id in sorted(
        buckets.keys(),
        key=lambda i: (
            -(
                buckets[i]["confirmed_outflow"]  # type: ignore[operator]
                + buckets[i]["confirmed_inflow"]
            ),
            buckets[i]["company_name"] or "",
            i,
        ),
    ):
        bucket = buckets[cp_id]
        conf_out = bucket["confirmed_outflow"]
        conf_in = bucket["confirmed_inflow"]
        assert isinstance(conf_out, Decimal) and isinstance(conf_in, Decimal)
        counterparties.append(
            {
                "company_id": cp_id,
                "company_name": bucket["company_name"],
                "confirmed_outflow": _money(conf_out),
                "confirmed_inflow": _money(conf_in),
                "net": _money(conf_out - conf_in),
                "pending_count": int(bucket["pending_count"]),
                "last_effective_at": bucket["last_effective_at"],
            }
        )

    # Enrich window with calculation-basis opening/ending when configured.
    balances = calculation.compute_company_balances(
        connection, company_id, cutoff=end
    )
    has_opening = balances.get("basis") == "full"
    opening_total = _ZERO
    ending_total = _ZERO
    if has_opening:
        for pair in balances.get("pairs") or []:
            opening_total += _as_decimal(pair.get("opening") or "0")
            ending_total += _as_decimal(pair.get("closing") or "0")

    return {
        "own_company": own,
        "window": {
            "start": start,
            "end": end,
            "has_opening": has_opening,
            "opening": _money(opening_total) if has_opening else None,
            "ending": _money(ending_total) if has_opening else None,
            "basis": balances.get("basis"),
            "calculation_start_date": balances.get("calculation_start_date"),
        },
        "confirmed": {
            "outflow_total": _money(outflow),
            "outflow_count": outflow_count,
            "inflow_total": _money(inflow),
            "inflow_count": inflow_count,
            "net_change": _money(net),
            "net_direction": net_direction,
        },
        "pending": {
            "count": len(seen_pending),
            "amount_total": _money(pending_total),
        },
        "counterparties": counterparties,
    }


# ---------------------------------------------------------------------------
# Detail list + CSV export (HEL-176)
# ---------------------------------------------------------------------------


def encode_cursor(effective_at: str, event_id: int) -> str:
    raw = f"{effective_at}|{event_id}"
    return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii")


def decode_cursor(cursor: str | None) -> tuple[str, int] | None:
    if not cursor:
        return None
    try:
        raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")
        effective_at, event_id_text = raw.split("|", 1)
        return effective_at, int(event_id_text)
    except Exception as exc:
        raise TransferSummaryInputError("分页游标无效。") from exc


def _parse_limit(raw: object | None, default: int = _DEFAULT_PAGE) -> int:
    if raw is None or str(raw).strip() == "":
        return default
    try:
        value = int(str(raw).strip())
    except (TypeError, ValueError) as exc:
        raise TransferSummaryInputError("limit 必须是正整数。") from exc
    if value < 1:
        raise TransferSummaryInputError("limit 必须是正整数。")
    return min(value, _MAX_PAGE)


def _parse_optional_date(raw: object | None, label: str) -> str | None:
    if raw is None or str(raw).strip() == "":
        return None
    return _validate_date(raw, label)


def _parse_direction(raw: object | None) -> str | None:
    if raw is None or str(raw).strip() == "":
        return None
    value = str(raw).strip().lower()
    if value not in ("out", "in"):
        raise TransferSummaryInputError("direction 只能是 out 或 in。")
    return value


def _parse_state(raw: object | None) -> str | None:
    if raw is None or str(raw).strip() == "":
        return None
    value = str(raw).strip().lower()
    if value not in ("confirmed", "pending"):
        raise TransferSummaryInputError("state 只能是 confirmed 或 pending。")
    return value


def _parse_counterparty_id(raw: object | None) -> int | None:
    if raw is None or str(raw).strip() == "":
        return None
    try:
        value = int(str(raw).strip())
    except (TypeError, ValueError) as exc:
        raise TransferSummaryInputError("counterparty_id 参数无效。") from exc
    if value < 1:
        raise TransferSummaryInputError("counterparty_id 参数无效。")
    return value


def _event_window(
    connection: sqlite3.Connection,
    *,
    from_: str | None,
    to: str | None,
) -> tuple[str, str]:
    end = _parse_optional_date(to, "to") or today_shanghai()
    start_default = settings.get_settings(connection).get("start_date") or "2026-01-01"
    start = _parse_optional_date(from_, "from") or _validate_date(
        start_default, "start_date"
    )
    if start > end:
        raise TransferSummaryInputError("from 不能晚于 to。")
    return start, end


# Confirmed = eligible_intercompany_events. Pending matches summary tip set.
_CONFIRMED_PREDICATE = """
    d.classification = 'intercompany'
    AND (d.pairing = 'paired' OR d.locked = 1)
"""

_PENDING_PREDICATE = """
    (
        d.classification IN ('unresolved', 'needs_review', 'internal_single')
     OR (
            d.classification = 'intercompany'
        AND d.pairing != 'paired'
        AND d.locked = 0
     )
    )
"""

_BOTH_PREDICATE = f"""
    (
        ({_CONFIRMED_PREDICATE})
     OR ({_PENDING_PREDICATE})
    )
"""


def _state_predicate(state: str | None) -> str:
    if state == "confirmed":
        return f"({_CONFIRMED_PREDICATE})"
    if state == "pending":
        return f"({_PENDING_PREDICATE})"
    return _BOTH_PREDICATE


def _list_select_sql() -> str:
    return """
        SELECT c.event_id, d.id AS decision_id, d.revision, d.classification,
               d.pairing, d.amount, d.currency, d.effective_at, d.mode,
               d.locked, d.rule_version, d.created_at, d.reason,
               payer.company_id AS payer_company_id,
               payee.company_id AS payee_company_id,
               payer.bank_account_id AS payer_account_id,
               payee.bank_account_id AS payee_account_id,
               cpayer.name AS payer_company_name,
               cpayee.name AS payee_company_name,
               (SELECT COUNT(*) FROM transfer_decision_observations o
                WHERE o.decision_id = d.id) AS evidence_count,
               (SELECT r.summary
                FROM transfer_decision_observations o
                JOIN source_rows r ON r.id = o.source_row_id
                JOIN sheet_batches s ON s.id = r.sheet_batch_id
                JOIN import_batches b ON b.id = s.import_batch_id
                WHERE o.decision_id = d.id AND b.company_id = ?
                ORDER BY o.id
                LIMIT 1) AS summary
        FROM current_transfer_decisions c
        JOIN transfer_match_decisions d ON d.id = c.decision_id
        JOIN canonical_transfer_events e ON e.id = c.event_id
        LEFT JOIN transfer_decision_participants payer
             ON payer.decision_id = d.id AND payer.role = 'payer'
        LEFT JOIN transfer_decision_participants payee
             ON payee.decision_id = d.id AND payee.role = 'payee'
        LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
        LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
    """


def _build_event_filters(
    *,
    company_id: int,
    start: str,
    end: str,
    counterparty_id: int | None,
    direction: str | None,
    state: str | None,
    cursor: tuple[str, int] | None,
) -> tuple[str, list[object]]:
    conditions = [
        "e.lifecycle = 'active'",
        "(payer.company_id = ? OR payee.company_id = ?)",
        "d.effective_at >= ?",
        "d.effective_at <= ?",
        _state_predicate(state),
    ]
    params: list[object] = [
        company_id,
        company_id,
        start,
        end + "T23:59:59",
    ]

    if counterparty_id is not None:
        # Counterparty is the other participant; own company stays forced above.
        conditions.append(
            """
            (
                (payer.company_id = ? AND payee.company_id = ?)
             OR (payee.company_id = ? AND payer.company_id = ?)
            )
            """
        )
        params.extend([company_id, counterparty_id, company_id, counterparty_id])

    if direction == "out":
        conditions.append("payer.company_id = ?")
        params.append(company_id)
    elif direction == "in":
        conditions.append("payee.company_id = ?")
        params.append(company_id)

    if cursor is not None:
        cursor_at, cursor_id = cursor
        conditions.append(
            """
            (
                d.effective_at < ?
             OR (d.effective_at = ? AND c.event_id < ?)
            )
            """
        )
        params.extend([cursor_at, cursor_at, cursor_id])

    where = " WHERE " + " AND ".join(conditions)
    return where, params


def _row_state(row: sqlite3.Row) -> str:
    classification = row["classification"]
    pairing = row["pairing"]
    locked = bool(row["locked"])
    if classification == "intercompany" and (pairing == "paired" or locked):
        return "confirmed"
    return "pending"


def _row_direction(row: sqlite3.Row, company_id: int) -> str | None:
    if row["payer_company_id"] is not None and int(row["payer_company_id"]) == int(
        company_id
    ):
        return "out"
    if row["payee_company_id"] is not None and int(row["payee_company_id"]) == int(
        company_id
    ):
        return "in"
    return None


def _event_list_item(row: sqlite3.Row, company_id: int) -> dict[str, object]:
    direction = _row_direction(row, company_id)
    state = _row_state(row)
    if direction == "out":
        counterparty_company_id = row["payee_company_id"]
        counterparty_company_name = row["payee_company_name"]
    else:
        counterparty_company_id = row["payer_company_id"]
        counterparty_company_name = row["payer_company_name"]
    summary = row["summary"] or row["reason"] or ""
    return {
        "event_id": int(row["event_id"]),
        "decision_id": int(row["decision_id"]),
        "revision": row["revision"],
        "classification": row["classification"],
        "pairing": row["pairing"],
        "status": matching.exposed_status(row),
        "state": state,
        "direction": direction,
        "amount": row["amount"],
        "currency": row["currency"],
        "effective_at": row["effective_at"],
        "mode": row["mode"],
        "locked": bool(row["locked"]),
        "rule_version": row["rule_version"],
        "summary": summary,
        "own_company_id": company_id,
        "counterparty_company_id": (
            int(counterparty_company_id) if counterparty_company_id is not None else None
        ),
        "counterparty_company_name": counterparty_company_name,
        "evidence_count": row["evidence_count"],
    }


def company_intercompany_events(
    connection: sqlite3.Connection,
    *,
    company_id: int,
    from_: str | None = None,
    to: str | None = None,
    counterparty_id: int | None | object = None,
    direction: str | None | object = None,
    state: str | None | object = None,
    limit: object | None = None,
    cursor: str | None = None,
) -> dict[str, object]:
    """Filtered keyset page of company-visible transfer events (HEL-176)."""
    start, end = _event_window(connection, from_=from_, to=to)
    cp_id = _parse_counterparty_id(counterparty_id)
    direction_value = _parse_direction(direction)
    state_value = _parse_state(state)
    page_size = _parse_limit(limit)
    cursor_tuple = decode_cursor(cursor)

    where, params = _build_event_filters(
        company_id=company_id,
        start=start,
        end=end,
        counterparty_id=cp_id,
        direction=direction_value,
        state=state_value,
        cursor=cursor_tuple,
    )
    # summary subquery binds own company_id first.
    sql = (
        _list_select_sql()
        + where
        + " ORDER BY d.effective_at DESC, c.event_id DESC LIMIT ?"
    )
    rows = connection.execute(
        sql, (company_id, *params, page_size + 1)
    ).fetchall()

    has_more = len(rows) > page_size
    page = rows[:page_size]
    items = [_event_list_item(row, company_id) for row in page]
    next_cursor = None
    if has_more and page:
        last = page[-1]
        next_cursor = encode_cursor(str(last["effective_at"]), int(last["event_id"]))

    return {
        "window": {"start": start, "end": end},
        "events": items,
        "next_cursor": next_cursor,
        "has_more": has_more,
    }


def company_intercompany_export_rows(
    connection: sqlite3.Connection,
    *,
    company_id: int,
    from_: str | None = None,
    to: str | None = None,
    counterparty_id: int | None | object = None,
    direction: str | None | object = None,
) -> tuple[list[dict[str, object]], dict[str, object]]:
    """Confirmed-only rows for CSV export; pending never included."""
    start, end = _event_window(connection, from_=from_, to=to)
    cp_id = _parse_counterparty_id(counterparty_id)
    direction_value = _parse_direction(direction)

    where, params = _build_event_filters(
        company_id=company_id,
        start=start,
        end=end,
        counterparty_id=cp_id,
        direction=direction_value,
        state="confirmed",
        cursor=None,
    )
    sql = (
        _list_select_sql()
        + where
        + " ORDER BY d.effective_at DESC, c.event_id DESC LIMIT ?"
    )
    rows = connection.execute(
        sql, (company_id, *params, _MAX_EXPORT_ROWS + 1)
    ).fetchall()
    if len(rows) > _MAX_EXPORT_ROWS:
        raise TransferSummaryInputError(
            f"导出行数超过上限 {_MAX_EXPORT_ROWS}，请缩小筛选范围。"
        )
    items = [_event_list_item(row, company_id) for row in rows]
    meta = {
        "start": start,
        "end": end,
        "counterparty_id": cp_id,
        "direction": direction_value,
        "state": "confirmed",
        "row_count": len(items),
    }
    return items, meta


def render_intercompany_export_csv(items: list[dict[str, object]]) -> bytes:
    buffer = io.StringIO()
    writer = csv.writer(buffer)
    writer.writerow(
        [
            "日期",
            "方向",
            "对方公司",
            "金额",
            "币种",
            "摘要",
            "状态",
            "配对",
            "事件ID",
            "决策ID",
        ]
    )
    direction_label = {"out": "转出", "in": "转入"}
    for item in items:
        writer.writerow(
            [
                item.get("effective_at") or "",
                direction_label.get(str(item.get("direction") or ""), ""),
                item.get("counterparty_company_name") or "",
                item.get("amount") or "",
                item.get("currency") or "",
                item.get("summary") or "",
                "已确认",
                item.get("pairing") or "",
                item.get("event_id") or "",
                item.get("decision_id") or "",
            ]
        )
    # UTF-8 BOM so Excel opens the CSV with the right encoding.
    return (chr(0xFEFF) + buffer.getvalue()).encode("utf-8")


def is_transfer_summary_events_query(query: dict[str, list[str]]) -> bool:
    """Discriminate HEL-176 transfer list from B-44 ledger ``/events``.

    B-44 uses ``cutoff`` / subject / posting_kind / source_kind / pending_subject.
    HEL-176 uses ``to`` / direction / counterparty_id / state=pending|confirmed
    (without ledger-only knobs).
    """
    if (query.get("direction") or [None])[0] is not None:
        return True
    if (query.get("counterparty_id") or [None])[0] is not None:
        return True
    if (query.get("to") or [None])[0] is not None:
        return True
    state = (query.get("state") or [None])[0]
    if state in ("pending", "confirmed") and (query.get("cutoff") or [None])[0] is None:
        # Bare state=confirmed without cutoff is the transfer-summary list;
        # B-44 confirmed always pairs with cutoff in existing callers/tests.
        if (query.get("subject") or [None])[0] is not None:
            return False
        if (query.get("posting_kind") or [None])[0] is not None:
            return False
        if (query.get("source_kind") or [None])[0] is not None:
            return False
        return True
    return False
