"""DataTools Reconcile — Streamlit page.

Two-source reconciliation (e.g. bank feed vs. ledger): upload both
files, pick the amount/date columns on each side, choose tolerance
settings, then download four output CSVs (matched, unmatched-left,
unmatched-right, review).
"""

from __future__ import annotations

import io
import sys
from pathlib import Path

import pandas as pd
import streamlit as st

_project_root = Path(__file__).resolve().parent.parent.parent.parent
if str(_project_root) not in sys.path:
    sys.path.insert(0, str(_project_root))

from src.audit import log_event, log_page_open
from src.gui.components import (
    back_to_home_link,
    hide_streamlit_chrome,
    html_download_button,
    render_sticky_footer,
    render_tool_header,
)
from src.core.reconcile import ReconcileOptions, reconcile

hide_streamlit_chrome()
render_sticky_footer()
back_to_home_link()
log_page_open("11_Reconciler")


# ---------------------------------------------------------------------------
# Header
# ---------------------------------------------------------------------------

render_tool_header("11_reconciler")


# ---------------------------------------------------------------------------
# File readers
# ---------------------------------------------------------------------------


@st.cache_data(show_spinner=False)
def _read_uploaded(name: str, data: bytes) -> pd.DataFrame:
    """Read uploaded bytes into a DataFrame. Mirrors the helper used by
    other tool pages — keeps everything as strings so the user controls
    coercion via the column-type selectors below."""
    suffix = Path(name).suffix.lower()
    bio = io.BytesIO(data)
    if suffix in (".xlsx", ".xls"):
        return pd.read_excel(bio, dtype=str, keep_default_na=False)
    for enc in ("utf-8", "utf-8-sig", "latin-1"):
        try:
            bio.seek(0)
            sep = "\t" if suffix == ".tsv" else ","
            return pd.read_csv(
                bio, dtype=str, keep_default_na=False,
                encoding=enc, sep=sep, on_bad_lines="warn",
            )
        except UnicodeDecodeError:
            continue
    bio.seek(0)
    return pd.read_csv(bio, dtype=str, keep_default_na=False, encoding="latin-1")


def _side_panel(side_label: str, key_prefix: str):
    """Render one side's upload + preview. Returns the DataFrame or None."""
    st.markdown(f"**{side_label}**")
    upload = st.file_uploader(
        f"Upload {side_label.lower()} file (CSV / Excel)",
        type=["csv", "tsv", "xlsx", "xls"],
        key=f"{key_prefix}_upload",
        label_visibility="collapsed",
    )
    if upload is None:
        st.caption(f"_No {side_label.lower()} file yet._")
        return None, None
    try:
        df = _read_uploaded(upload.name, upload.getvalue())
    except Exception as e:
        st.error(f"Could not read `{upload.name}`: {e}")
        return None, None
    st.caption(f"`{upload.name}` — {len(df)} rows, {len(df.columns)} columns")
    with st.expander(f"Preview {side_label.lower()}", expanded=False):
        st.dataframe(df.head(10), width="stretch")
    return df, upload.name


# ---------------------------------------------------------------------------
# Side-by-side upload
# ---------------------------------------------------------------------------

col_left, col_right = st.columns(2)
with col_left:
    left_df, left_name = _side_panel("Left (e.g. bank feed)", "left")
with col_right:
    right_df, right_name = _side_panel("Right (e.g. ledger)", "right")

if left_df is None or right_df is None:
    st.info("Upload both files to continue.")
    st.stop()


# ---------------------------------------------------------------------------
# Column mapping
# ---------------------------------------------------------------------------

st.divider()
st.subheader("Match settings")

map_left, map_right = st.columns(2)


# Name-pattern heuristics for auto-detecting which file column plays
# which semantic role. First-match-wins per role, scanning the file's
# columns in their on-disk order so a Date selector lands where the
# user expects to see it.
_AMOUNT_PATTERNS = ("amount", "amt", "value", "total", "debit", "credit", "sum")
_DATE_PATTERNS = ("date", "posted", "posting", "transaction date", "txn date", "when")
_DESC_PATTERNS = ("description", "desc", "memo", "narrative", "payee", "vendor", "name", "details")


def _detect_role(columns: list[str], patterns: tuple[str, ...]) -> str | None:
    """Return the first column whose name contains any of *patterns*."""
    lowered = [(c, str(c).lower()) for c in columns]
    for pat in patterns:
        for original, low in lowered:
            if pat in low:
                return original
    return None


def _col_pick(
    label: str,
    df: pd.DataFrame,
    key: str,
    *,
    allow_none: bool,
    default: str | None = None,
):
    """Selectbox for picking a column. Pre-selects *default* if present."""
    cols = list(df.columns)
    options = ["(none)"] + cols if allow_none else cols
    if default is not None and default in cols:
        index = options.index(default)
    else:
        index = 0
    pick = st.selectbox(label, options, key=key, index=index)
    return None if pick == "(none)" else pick


def _render_side_pickers(
    side_label: str,
    df: pd.DataFrame,
    key_prefix: str,
    *,
    keys_help: str,
):
    """Render the four column selectors in the order their detected
    columns appear in the file. Roles that go undetected fall to the
    bottom in a stable default order so the layout is predictable
    when the file uses unusual column names."""
    st.markdown(f"**{side_label}**")
    cols = list(df.columns)
    detected = {
        "amount": _detect_role(cols, _AMOUNT_PATTERNS),
        "date":   _detect_role(cols, _DATE_PATTERNS),
        "desc":   _detect_role(cols, _DESC_PATTERNS),
    }
    # Position within the file's column order. Undetected roles get a
    # large sentinel so they sort to the end without disturbing the
    # detected ones' relative order.
    def _position(role: str) -> int:
        col = detected.get(role)
        return cols.index(col) if col in cols else 10**9

    # Stable default among undetected: amount → date → desc (the order
    # a brand-new user is most likely to think about). The multiselect
    # for reference keys always renders last because it's the least
    # frequently used.
    role_order = sorted(["amount", "date", "desc"], key=lambda r: (_position(r), ["amount","date","desc"].index(r)))

    picks: dict[str, str | None] = {}
    for role in role_order:
        if role == "amount":
            picks["amount"] = _col_pick(
                "Amount column", df, f"{key_prefix}_amount_col",
                allow_none=False, default=detected["amount"],
            )
        elif role == "date":
            picks["date"] = _col_pick(
                "Date column (optional)", df, f"{key_prefix}_date_col",
                allow_none=True, default=detected["date"],
            )
        elif role == "desc":
            picks["desc"] = _col_pick(
                "Description column (optional)", df, f"{key_prefix}_desc_col",
                allow_none=True, default=detected["desc"],
            )

    picks["keys"] = st.multiselect(
        f"Reference columns ({keys_help})",
        cols, key=f"{key_prefix}_keys_col",
    )
    return picks


with map_left:
    left_picks = _render_side_pickers(
        "Left columns", left_df, "left",
        keys_help="optional, e.g. check / invoice no.",
    )
with map_right:
    right_picks = _render_side_pickers(
        "Right columns", right_df, "right",
        keys_help="must match left count",
    )

left_amount = left_picks["amount"]
left_date = left_picks["date"]
left_desc = left_picks["desc"]
left_keys = left_picks["keys"]
right_amount = right_picks["amount"]
right_date = right_picks["date"]
right_desc = right_picks["desc"]
right_keys = right_picks["keys"]

# ---------------------------------------------------------------------------
# Tolerances & options
# ---------------------------------------------------------------------------

with st.expander("Tolerances & options", expanded=True):
    tol_a, tol_b, tol_c = st.columns(3)
    with tol_a:
        amount_tolerance = st.number_input(
            "Amount tolerance",
            min_value=0.0, value=0.0, step=0.01, format="%.4f",
            help="Absolute tolerance on amount (e.g. 0.01 to absorb cent rounding).",
        )
    with tol_b:
        date_tolerance = st.number_input(
            "Date tolerance (days)",
            min_value=0, value=0, step=1,
            help="Allow N calendar days of drift between posting dates.",
        )
    with tol_c:
        invert_right_sign = st.checkbox(
            "Invert right amount sign",
            value=False,
            help="Use when one side records debits as positive and the other as negative.",
        )
    desc_min_score = st.slider(
        "Description similarity boost (0 disables)",
        min_value=0, max_value=100, value=0, step=5,
        help=(
            "When both sides have a description column set, accept matches with "
            "this minimum fuzzy similarity even if amount/date are merely within "
            "tolerance. Lower = more permissive."
        ),
    )

# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------

st.divider()

if st.button("Reconcile", type="primary", width="stretch"):
    if len(left_keys) != len(right_keys):
        st.error(
            "Reference columns must match in count: "
            f"left has {len(left_keys)}, right has {len(right_keys)}."
        )
        st.stop()
    options = ReconcileOptions(
        left_amount=left_amount,
        right_amount=right_amount,
        left_date=left_date,
        right_date=right_date,
        left_keys=list(left_keys),
        right_keys=list(right_keys),
        left_desc=left_desc,
        right_desc=right_desc,
        desc_min_score=int(desc_min_score),
        amount_tolerance=float(amount_tolerance),
        date_tolerance_days=int(date_tolerance),
        invert_right_sign=bool(invert_right_sign),
    )
    with st.spinner("Reconciling..."):
        try:
            result = reconcile(left_df, right_df, options)
        except ValueError as e:
            st.error(str(e))
            st.stop()
    st.session_state["reconcile_result"] = result
    st.session_state["reconcile_left_name"] = left_name
    log_event("tool_run", "Reconcile run", page="11_Reconciler")

result = st.session_state.get("reconcile_result")
if result is None:
    st.stop()

# ---------------------------------------------------------------------------
# Results
# ---------------------------------------------------------------------------

st.subheader("Results")

stats = result.stats
m1, m2, m3, m4 = st.columns(4)
m1.metric("Matched", stats["matched"])
m2.metric("Review", stats["review"])
m3.metric("Unmatched left", stats["unmatched_left"])
m4.metric("Unmatched right", stats["unmatched_right"])

# Health bar: matched / max(left, right)
denom = max(stats["left_rows"], stats["right_rows"]) or 1
pct = stats["matched"] / denom * 100
st.caption(f"Coverage: {pct:.1f}% of the larger side")

tab_matched, tab_review, tab_left, tab_right = st.tabs(
    [
        f"Matched ({stats['matched']})",
        f"Review ({stats['review']})",
        f"Unmatched left ({stats['unmatched_left']})",
        f"Unmatched right ({stats['unmatched_right']})",
    ]
)

# Default preview cap. The full data is still available via the download
# buttons below — capping the on-screen render keeps a 50k-row recon
# from freezing the Streamlit page on first paint.
_PREVIEW_ROWS = 25


def _render_preview(
    df: pd.DataFrame,
    *,
    empty_msg: str,
    caption: str | None = None,
):
    """Show a row-limited preview of *df* with a header caption."""
    if df.empty:
        st.info(empty_msg)
        return
    if caption:
        st.caption(caption)
    if len(df) > _PREVIEW_ROWS:
        st.caption(
            f"Preview of first {_PREVIEW_ROWS} of {len(df)} rows — "
            f"download the CSV below for the full set."
        )
    else:
        st.caption(f"All {len(df)} rows shown.")
    st.dataframe(df.head(_PREVIEW_ROWS), width="stretch", hide_index=True)


with tab_matched:
    _render_preview(
        result.matched,
        empty_msg="No matches.",
    )

with tab_review:
    _render_preview(
        result.review,
        empty_msg="Nothing to review — no ambiguous candidates.",
        caption=(
            "Pairs flagged because the algorithm couldn't pick a single "
            "best match (e.g. multiple equally-good candidates). Use the "
            "left/right indices to disambiguate manually."
        ),
    )

with tab_left:
    _render_preview(
        result.unmatched_left,
        empty_msg="Every left row was matched.",
    )

with tab_right:
    _render_preview(
        result.unmatched_right,
        empty_msg="Every right row was matched.",
    )

# ---------------------------------------------------------------------------
# Downloads
# ---------------------------------------------------------------------------

st.divider()
stem = Path(st.session_state.get("reconcile_left_name", "reconcile")).stem

dl_a, dl_b, dl_c, dl_d = st.columns(4)
with dl_a:
    html_download_button(
        "Matched CSV",
        result.matched.to_csv(index=False).encode("utf-8-sig"),
        file_name=f"{stem}_matched.csv",
        mime="text/csv",
        disabled=result.matched.empty,
    )
with dl_b:
    html_download_button(
        "Review CSV",
        result.review.to_csv(index=False).encode("utf-8-sig"),
        file_name=f"{stem}_review.csv",
        mime="text/csv",
        disabled=result.review.empty,
    )
with dl_c:
    html_download_button(
        "Unmatched left",
        result.unmatched_left.to_csv(index=False).encode("utf-8-sig"),
        file_name=f"{stem}_unmatched_left.csv",
        mime="text/csv",
        disabled=result.unmatched_left.empty,
    )
with dl_d:
    html_download_button(
        "Unmatched right",
        result.unmatched_right.to_csv(index=False).encode("utf-8-sig"),
        file_name=f"{stem}_unmatched_right.csv",
        mime="text/csv",
        disabled=result.unmatched_right.empty,
    )
